새소식

TIL

[TIL] 230613 <Spring> 테스트

  • -

버그 발견 시간이 늦어짐에 따라 비용이 기하급수적으로 커짐!

 

  1. Development: 개발
  2. Unit Tests (단위 테스트): 개발자 테스트
  3. QA Testing:
    • 블랙박스 테스팅
    • 주로 QA 팀이 Production 환경과 유사한 환경(Stage)에서 테스팅
  4. Production: 실 서비스 운영 환경
  • 따라서 테스트 코드를 작성한다면 프로그램의 버그를 사전에 발견하여 기하급수적인 비용의 증가가능성을 사전에 방지가능
  • 개발자는 단위 테스트를 작성하여 프로그램을 테스트
    • 단위 테스트는 작은 단위로 쪼개서 각 단위가 정확하게 동작하는지를 검사하는 테스트 기법
    • 빠르게 작성할 수 있고 문제 발생 시 어느 부분이 잘못 되었는지를 빠르고 정확하게 확인 가능!

 

 

 

JUnit 5 User Guide

Although the JUnit Jupiter programming model and extension model do not support JUnit 4 features such as Rules and Runners natively, it is not expected that source code maintainers will need to update all of their existing tests, test extensions, and custo

junit.org

 

▶ Before-After

public class BeforeAfterTest {

	@BeforeEach
	void setUp() {
		System.out.println("각각의 테스트 코드가 실행되기 전에 수행");
	}

	@AfterEach
	void tearDown() {
		System.out.println("각각의 테스트 코드가 실행된 후에 수행\n");
	}

	@BeforeAll
	static void beforeAll() {  //static 메서드로 만들어야함
		System.out.println("모든 테스트 코드가 실행되기 전에 최초로 수행\n");
	}

	@AfterAll
	static void afterAll() {  //static 메서드로 만들어야함
		System.out.println("모든 테스트 코드가 수행된 후 마지막으로 수행");
	}


	@Test
	void test1() {
		System.out.println("test1");
	}

	@Test
	void test2() {
		System.out.println("test2");
	}
}

 

▶ 테스트 꾸미기

public class CustomTest {

	@Test
	@DisplayName("테스트의 내용을 한눈에 알아볼 수 있게 네이밍 해줄 수 있습니다.")
	void test1() {
		System.out.println("테스트의 수행 내용들을 빠르게 파악할 수 있습니다.");
	}

	@Nested
	@DisplayName("주제 별로 테스트를 그룹지어서 파악하기 좋습니다.")
	@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
	class Test1 {

		@Order(1)
		@Test
		@DisplayName("Test1 클래스")
		void test() {
			System.out.println("\nTest1 클래스");
		}

		@Order(3)
		@Test
		@DisplayName("Test1 - test1()")
		void test1() {
			System.out.println("Test1.test1");
		}

		@Order(2)
		@Test
		@DisplayName("Test1 - test2()")
		void test2() {
			System.out.println("Test1.test2");
		}
	}

	@Nested
	@DisplayName("Test2 다른 주제")
	class Test2 {
		@Test
		@DisplayName("Test2 - test1()")
		void test1() {
			System.out.println("Test2.test1");
		}

		@Test
		@DisplayName("Test2 - test2()")
		void test2() {
			System.out.println("Test2.test2");
		}
	}
}

  • 테스트를 메서드 단위로 순서를 매길 때는 @TestMethodOrder(MethodOrderer.OrderAnnotation.class) 애너테이션 설정
  • 그 후 원하는 순서에 맞게 메서드에 @Order(순서) 애너테이션 추가

 

▶ 테스트 반복하기

public class RepeatTest {

	// @RepeatedTest를 사용하여 해당 테스트 메서드를 반복가능
	@RepeatedTest(value = 5, name = "반복 테스트 {currentRepetition} / {totalRepetitions}")  //name 속성을 사용하여 네이밍가능
	void repeatTest(RepetitionInfo info) {  //RepetitionInfo 값을 파라미터로 받아서 현재 반복 횟수와 총 횟수 값을 확인가능
		System.out.println("테스트 반복 : " + info.getCurrentRepetition() + " / " + info.getTotalRepetitions());
	}


	@DisplayName("파라미터 값 활용하여 테스트 하기")
	@ParameterizedTest  //@ParameterizedTest를 사용하여 파라미터를 받아 테스트할 수 있는 메서드 생성가능
	@ValueSource(ints = {1, 2, 3, 4, 5, 6, 7, 8, 9})  //파라미터 값을 전달가능
	// 전달되는 파라미터 수만큼 테스트 메서드 수행
	void parameterTest(int num) {  //int, String 등 여러 타입의 파라미터를 전달가능
		System.out.println("5 * num = " + 5 * num);
	}

}

 

▶ Assertions (검증)

Calculator

더보기

public class Calculator {
    public Double operate(double num1, String op, double num2) {
        switch (op) {
            case "*":
                return num1 * num2;
            case "/":
                if (validateNum(num2)) {
                    return num1 / num2;
                } else {
                    return null;
                }
            case "+":
                return num1 + num2;
            case "-":
                return num1 - num2;
            default:
                throw new IllegalArgumentException("잘못된 연산자입니다.");
        }
    }

    public boolean validateNum(double num) {
        if (num == 0) {
            return false;
        } else {
            return true;
        }
    }
}

 

⊙ Assertions.assertEquals(expected, actual)

public class AssertionTest {
	Calculator calculator;

	@BeforeEach
	void setUp() {
		calculator = new Calculator();
	}


	@Test
	@DisplayName("assertEquals")
	void test1() {
		Double result = calculator.operate(5, "/", 2);
		//assertEquals() 메서드는 첫 번째 파라미터에 예상값을 넣고 두 번째 파라미터에 테스트 결과값(실제값) 넣어줌
		assertEquals(2.5, result);  //예상값과 실제값이 다르면 테스트가 실패
	}

	@Test
	@DisplayName("assertEquals - Supplier")
	void test1_1() {
		Double result = calculator.operate(5, "/", 0);
		// 3번째 파라미터 값에 람다식으로 메시지를 넣어두면 테스트 실패 시 해당 메시지가 출력 (new Supplier<String>())
		assertEquals(2.5, result, () -> "연산자 혹은 분모가 0이 아닌지 확인해보세요!");
	}

	@Test
	@DisplayName("assertNotEquals")
	void test1_2() {
		Double result = calculator.operate(5, "/", 0);
		assertNotEquals(2.5, result);
	}
}

 

Assertions.assertTrue(boolean)

	@Test
	@DisplayName("assertTrue 와 assertFalse")
	void test2() {
		assertTrue(calculator.validateNum(9));  //assertTrue() 메서드는 해당 파라미터 값이 true인지 확인
		assertFalse(calculator.validateNum(0));
	}

 

Assertions.assertNotNull(actual)

	@Test
	@DisplayName("assertNotNull 과 assertNull")
	void test3() {
		Double result1 = calculator.operate(5, "/", 2);
		assertNotNull(result1);  //assertNotNull() 메서드는 해당 파라미터 값이 null이 아님을 확인
		Double result2 = calculator.operate(5, "/", 0);
		assertNull(result2);
	}

 

⊙Assertions.assertThrows(expectedType, executable)

	@Test
	@DisplayName("assertThrows")
	void test4() {
		//assertThrows() 메서드는 첫 번째 파라미터에 예상하는 Exception 클래스 타입, 두 번째 파라미터에 실행 코드
		//실행 코드의 결과가 예상한 해당 클래스 타입이라면 테스트에 성공
		IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> calculator.operate(5, "?", 2));
		assertEquals("잘못된 연산자입니다.", exception.getMessage());  //예외 메시지도 테스트 가능
	}

 

 

Given - When - Then (테스트코드 작성 스타일 표현 방식)

Given

  • 테스트 하고자하는 대상을 실제로 실행하기 전에 테스트에 필요한 값(상태)을 미리 선언해둠

When

  • 테스트 하고자하는 대상을 실제로 실행

Then

  • 어떤 특정한 행동(테스트 대상 실행) 때문에 발생할거라고 예상되는 결과에 대해 예측하고 맞는지 확인
class CalculatorTest {

	Calculator calculator;

	@BeforeEach
	void setUp() {
		calculator = new Calculator();
	}

	@Test
	@DisplayName("계산기 연산 성공 테스트")
	void test1() {
		// given
		int num1 = 5;
		String op = "/";
		int num2 = 2;

		// when
		Double result = calculator.operate(num1, op, num2);

		// then
		assertNotNull(result);
		assertEquals(2.5, result);
	}

	@Test
	@DisplayName("계산기 연산 실패 테스트 : 분모가 0일 경우")
	void test1_1() {
		// given
		int num1 = 5;
		String op = "/";
		int num2 = 0;

		// when
		Double result = calculator.operate(num1, op, num2);

		// then
		assertNull(result);
	}

	@Test
	@DisplayName("계산기 연산 실패 테스트 : 연산자가 잘못됐을 경우")
	void test1_2() {
		// given
		int num1 = 5;
		String op = "?";
		int num2 = 2;

		// when - then
		IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> calculator.operate(5, "?", 2));
		assertEquals("잘못된 연산자입니다.", exception.getMessage());
	}
}

 

 


myselectshop > ProductService > updateProduct의 단위 테스트 작성

	@Transactional
	public ProductResponseDto updateProduct(Long id, ProductMypriceRequestDto requestDto) {
		int myprice = requestDto.getMyprice();
		if (myprice < MIN_MY_PRICE) {
			throw new IllegalArgumentException("유효하지 않은 관심 가격입니다. 최소 " + MIN_MY_PRICE + " 원 이상으로 설정해 주세요.");
		}

		Product product = productRepository.findById(id).orElseThrow(() ->
			new NullPointerException("해당 상품을 찾을 수 없습니다.")
		);

		product.update(requestDto);

		return new ProductResponseDto(product);
	}
class ProductServiceTest {
	@Test
	@DisplayName("관심 상품 희망가 - 최저가 이상으로 변경")
	void test1() {
		// given
		Long productId = 100L;
		int myprice = ProductService.MIN_MY_PRICE + 3_000_000;

		ProductMypriceRequestDto requestMyPriceDto = new ProductMypriceRequestDto();
		requestMyPriceDto.setMyprice(myprice);

		ProductService productService = new ProductService();

		// when
		ProductResponseDto result = productService.updateProduct(productId, requestMyPriceDto);

		// then
		assertEquals(myprice, result.getMyprice());
	}

	@Test
	@DisplayName("관심 상품 희망가 - 최저가 미만으로 변경")
	void test2() {
		// given
		Long productId = 200L;
		int myprice = ProductService.MIN_MY_PRICE - 50;

		ProductMypriceRequestDto requestMyPriceDto = new ProductMypriceRequestDto();
		requestMyPriceDto.setMyprice(myprice);

		ProductService productService = new ProductService();

		// when
		Exception exception = assertThrows(IllegalArgumentException.class, () -> {
			productService.updateProduct(productId, requestMyPriceDto);
		});

		// then
		assertEquals(
			"유효하지 않은 관심 가격입니다. 최소 " +ProductService.MIN_MY_PRICE + " 원 이상으로 설정해 주세요.",
			exception.getMessage()
		);
	}
}
  • ProductService의 updateProduct 메서드를 테스트하려면 ProductService 객체를 생성할 때 생성자로
    ProductRepository, FolderRepository, ProductFolderRepository를 전달해줘야함
  • 하지만 전달하려해도 인터페이스들인데 어떻게 전달???
  • 전달한다고 해도 updateProduct 메서드 내부의 productRepository.findById(id) 코드는 어떻게 처리???

  • Controller 클래스만 테스트할 수 없을까?
    • 테스트 범위: Controller, Service, Repository
  • Service 클래스만 테스트할 수 없을까?
    • 테스트 범위: Service, Repository
  • Repository 클래스만 테스트할 수 없을까?
    • 테스트 범위: Repository

 

▼ ▼ ▼ ▼ ▼

Mock object (가짜 객체)
ㄴ 이상적으로, 각 테스트 케이스는 서로 분리되어야 하므로, 이를 위해 가짜 객체(Mock object)를 생성하는 것도 좋은 방법!

  • 가짜 객체(Mock object)로 분리!
  • MockRepository
    • 실제 객체와 겉만 같은 객체 (동일한 클래스명, 함수명)
    • 실제 DB 작업은 하지 않음
      • DB 작업이 이뤄지는 것처럼!
      • 테스트를 위해 필요한 결과값을 return
  • 이하 간단히 'mock'(목)이라고 부름

 

 

@ExtendWith(MockitoExtension.class) // @Mock 사용을 위해 설정합니다.
class ProductServiceTest {

	@Mock
	ProductRepository productRepository;

	@Mock
	FolderRepository folderRepository;

	@Mock
	ProductFolderRepository productFolderRepository;

	@Test
	@DisplayName("관심 상품 희망가 - 최저가 이상으로 변경")
	void test1() {
		// given
		Long productId = 100L;
		int myprice = ProductService.MIN_MY_PRICE + 3_000_000;

		ProductMypriceRequestDto requestMyPriceDto = new ProductMypriceRequestDto();
		requestMyPriceDto.setMyprice(myprice);

		// !!Mock 사용 케이스!!
		User user = new User();
		ProductRequestDto requestProductDto = new ProductRequestDto(
			"Apple <b>맥북</b> <b>프로</b> 16형 2021년 <b>M1</b> Max 10코어 실버 (MK1H3KH/A) ",
			"https://shopping-phinf.pstatic.net/main_2941337/29413376619.20220705152340.jpg",
			"https://search.shopping.naver.com/gate.nhn?id=29413376619",
			3515000
		);

		Product product = new Product(requestProductDto, user);

		ProductService productService = new ProductService(productRepository, folderRepository, productFolderRepository);

		// 넣어줄 부분 코드, 반환할 값
		given(productRepository.findById(productId)).willReturn(Optional.of(product));

		// when
		ProductResponseDto result = productService.updateProduct(productId, requestMyPriceDto);

		// then
		assertEquals(myprice, result.getMyprice());
	}

	@Test
	@DisplayName("관심 상품 희망가 - 최저가 미만으로 변경")
	void test2() {
		// given
		Long productId = 200L;
		int myprice = ProductService.MIN_MY_PRICE - 50;

		ProductMypriceRequestDto requestMyPriceDto = new ProductMypriceRequestDto();
		requestMyPriceDto.setMyprice(myprice);

		ProductService productService = new ProductService(productRepository, folderRepository, productFolderRepository);

		// when
		Exception exception = assertThrows(IllegalArgumentException.class, () -> {
			productService.updateProduct(productId, requestMyPriceDto);
		});

		// then
		assertEquals(
			"유효하지 않은 관심 가격입니다. 최소 " +ProductService.MIN_MY_PRICE + " 원 이상으로 설정해 주세요.",
			exception.getMessage()
		);
	}
}

 

 


  1. 단위 테스트 (Unit Test)
    • 하나의 모듈이나 클래스에 대해 세밀한 부분까지 테스트가 가능
    • 하지만 모듈 간에 상호 작용 검증은 할 수 없음
  2. 통합 테스트 (Integration Test)
    • 두 개 이상의 모듈이 연결된 상태를 테스트 가능
    • 모듈 간의 연결에서 발생하는 에러 검증 가능

      • Spring Boot를 이용한 통합 테스트
        • 여러 단위 테스트를 하나의 통합된 테스트로 수행
        • 단위 테스트 시 Spring은 동작되지 않음

        • @SpringBootTest
          • 스프링이 동작되도록 해주는 애너테이션
          • 테스트 수행 시 스프링이 동작
            • Spring IoC/DI 기능을 사용 가능
            • Repository를 사용해 DB CRUD가 가능

관심상품 통합 테스트

  1. 신규 관심상품 등록
    • User는 테스트 사용자인 1번 사용자
  2. 신규 등록된 관심상품의 희망 최저가 변경
    • 1번에서 등록한 관심상품의 희망 최저가를 변경
  3. 회원 Id 로 등록된 모든 관심상품 조회
    • 조회된 관심상품 중 1번에서 등록한 관심상품이 존재하는지?
    • 2번에서 업데이트한 내용이 잘 반영되었는지?

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) // 서버의 PORT를 랜덤으로 설정합니다.
@TestInstance(TestInstance.Lifecycle.PER_CLASS) // 테스트 인스턴스의 생성 단위를 클래스로 변경합니다 => 필드 공유 가능!!!
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)  //무조건 지정한 순서대로 동작하도록 해줌!
class ProductServiceIntegrationTest {

	//테스트 간 필드 공유 가능!!!
	@Autowired
	ProductService productService;
	@Autowired
	UserRepository userRepository;

	User user;
	ProductResponseDto createdProduct = null;
	int updatedMyPrice = -1;

	
	@Test
	@Order(1)
	@DisplayName("신규 관심상품 등록")
	void test1() {
		// given
		String title = "Apple <b>에어팟</b> 2세대 유선충전 모델 (MV7N2KH/A)";
		String imageUrl = "https://shopping-phinf.pstatic.net/main_1862208/18622086330.20200831140839.jpg";
		String linkUrl = "https://search.shopping.naver.com/gate.nhn?id=18622086330";
		int lPrice = 173900;
		ProductRequestDto requestDto = new ProductRequestDto(
			title,
			imageUrl,
			linkUrl,
			lPrice
		);
		user = userRepository.findById(1L).orElse(null);

		// when
		ProductResponseDto product = productService.createProduct(requestDto, user);

		// then
		assertNotNull(product.getId());
		assertEquals(title, product.getTitle());
		assertEquals(imageUrl, product.getImage());
		assertEquals(linkUrl, product.getLink());
		assertEquals(lPrice, product.getLprice());
		assertEquals(0, product.getMyprice());
		createdProduct = product;
	}

	@Test
	@Order(2)
	@DisplayName("신규 등록된 관심상품의 희망 최저가 변경")
	void test2() {
		// given
		Long productId = this.createdProduct.getId();
		int myPrice = 173000;
		ProductMypriceRequestDto requestDto = new ProductMypriceRequestDto();
		requestDto.setMyprice(myPrice);

		// when
		ProductResponseDto product = productService.updateProduct(productId, requestDto);

		// then
		assertNotNull(product.getId());
		assertEquals(this.createdProduct.getTitle(), product.getTitle());
		assertEquals(this.createdProduct.getImage(), product.getImage());
		assertEquals(this.createdProduct.getLink(), product.getLink());
		assertEquals(this.createdProduct.getLprice(), product.getLprice());
		assertEquals(myPrice, product.getMyprice());
		this.updatedMyPrice = myPrice;
	}

	@Test
	@Order(3)
	@DisplayName("회원이 등록한 모든 관심상품 조회")
	void test3() {
		// given
		// when
		Page<ProductResponseDto> productList = productService.getProducts(user,
			0, 10, "id", false);

		// then
		// 1. 전체 상품에서 테스트에 의해 생성된 상품 찾아오기 (상품의 id 로 찾음)
		Long createdProductId = this.createdProduct.getId();
		ProductResponseDto foundProduct = productList.stream()
			.filter(product -> product.getId().equals(createdProductId))
			.findFirst()
			.orElse(null);

		// 2. Order(1) 테스트에 의해 생성된 상품과 일치하는지 검증
		assertNotNull(foundProduct);
		assertEquals(this.createdProduct.getId(), foundProduct.getId());
		assertEquals(this.createdProduct.getTitle(), foundProduct.getTitle());
		assertEquals(this.createdProduct.getImage(), foundProduct.getImage());
		assertEquals(this.createdProduct.getLink(), foundProduct.getLink());
		assertEquals(this.createdProduct.getLprice(), foundProduct.getLprice());

		// 3. Order(2) 테스트에 의해 myPrice 가격이 정상적으로 업데이트되었는지 검증
		assertEquals(this.updatedMyPrice, foundProduct.getMyprice());
	}
}

 

 


test > mvc > MockSpringSecurityFilter

public class MockSpringSecurityFilter implements Filter {
	@Override
	public void init(FilterConfig filterConfig) {}

	@Override
	public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
		SecurityContextHolder.getContext()
			.setAuthentication((Authentication) ((HttpServletRequest) req).getUserPrincipal());  //인증 객체 생성
		chain.doFilter(req, res);
	}

	@Override
	public void destroy() {
		SecurityContextHolder.clearContext();
	}
}

 

test > mvc > UserProductMvcTest

@WebMvcTest(  //Controller 테스트 가능
	controllers = {UserController.class, ProductController.class},  //테스트할 Controller 지정
	excludeFilters = {  //제외할 것
		@ComponentScan.Filter(
			type = FilterType.ASSIGNABLE_TYPE,
			classes = WebSecurityConfig.class
		)
	}
) //Security 같이 사용 시 이런 식으로 설정하면 됨

class UserProductMvcTest {
	private MockMvc mvc;

	private Principal mockPrincipal;

	@Autowired
	private WebApplicationContext context;

	@Autowired
	private ObjectMapper objectMapper;

	@MockBean
	UserService userService;

	@MockBean
	KakaoService kakaoService;

	@MockBean
	ProductService productService;

	@MockBean
	FolderService folderService;

	@BeforeEach
	public void setup() {
		mvc = MockMvcBuilders.webAppContextSetup(context)
			.apply(springSecurity(new MockSpringSecurityFilter()))  //기존 security filter 취소했으니 새로 만들어준 필터 넣어줌
			.build();
	}

	private void mockUserSetup() {
		// Mock 테스트 유져 생성
		String username = "sollertia4351";
		String password = "robbie1234";
		String email = "sollertia@sparta.com";
		UserRoleEnum role = UserRoleEnum.USER;
		User testUser = new User(username, password, email, role);
		UserDetailsImpl testUserDetails = new UserDetailsImpl(testUser);  //직접 생성
		mockPrincipal = new UsernamePasswordAuthenticationToken(testUserDetails, "", testUserDetails.getAuthorities());    //직접 생성
	}

	@Test
	@DisplayName("로그인 Page")
	void test1() throws Exception {
		// when - then
		mvc.perform(get("/api/user/login-page"))
			.andExpect(status().isOk())  //예측
			.andExpect(view().name("login"))  //예측
			.andDo(print());
	}

	@Test
	@DisplayName("회원 가입 요청 처리")
	void test2() throws Exception {
		// given
		MultiValueMap<String, String> signupRequestForm = new LinkedMultiValueMap<>();
		signupRequestForm.add("username", "sollertia4351");
		signupRequestForm.add("password", "robbie1234");
		signupRequestForm.add("email", "sollertia@sparta.com");
		signupRequestForm.add("admin", "false");

		// when - then
		mvc.perform(post("/api/user/signup")
				.params(signupRequestForm)
			)
			.andExpect(status().is3xxRedirection())  //예측
			.andExpect(view().name("redirect:/api/user/login-page"))  //예측
			.andDo(print());
	}

	@Test
	@DisplayName("신규 관심상품 등록")
	void test3() throws Exception {
		// given
		this.mockUserSetup();  //회원필요
		String title = "Apple <b>아이폰</b> 14 프로 256GB [자급제]";
		String imageUrl = "https://shopping-phinf.pstatic.net/main_3456175/34561756621.20220929142551.jpg";
		String linkUrl = "https://search.shopping.naver.com/gate.nhn?id=34561756621";
		int lPrice = 959000;
		ProductRequestDto requestDto = new ProductRequestDto(
			title,
			imageUrl,
			linkUrl,
			lPrice
		);

		String postInfo = objectMapper.writeValueAsString(requestDto);  //클래스를 JSON타입의 String으로 변환

		// when - then
		mvc.perform(post("/api/products")
				.content(postInfo)
				.contentType(MediaType.APPLICATION_JSON)
				.accept(MediaType.APPLICATION_JSON)
				.principal(mockPrincipal)
			)
			.andExpect(status().isOk())  //예측
			.andDo(print());
	}
}

 

config > JpaConfig

@Configuration // 아래 설정을 등록하여 활성화 합니다.
@EnableJpaAuditing // 시간 자동 변경이 가능하도록 합니다.
public class JpaConfig {
}
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.