API 테스트
1. 의존성 추가
build.gradle에 들어가 assured 의존성을 추가합니다.
dependencies {
기존 디펜던시
implementation 'io.rest-assured:rest-assured:5.4.0'
}
2.ApiTest 생성
package com.example.productorderservice;
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ApiTest {
@LocalServerPort
private int port;
@BeforeEach
void setUp() {
RestAssured.port = port;
}
}
참고로 @SpringBootTest어노테이션란?
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)는 Spring Boot에서 제공하는 통합 테스트 어노테이션입니다. 이는 테스트 환경에서 애플리케이션을 실제 서버처럼 실행하여 테스트를 수행할 수 있도록 설정합니다.
@SpringBootTest란?
- Spring Boot 애플리케이션의 통합 테스트를 지원하는 어노테이션입니다.
- 애플리케이션 컨텍스트를 로드하고, 테스트 환경에서 Spring Bean을 사용할 수 있게 합니다.
- 주로 서비스 계층, 컨트롤러 계층, 또는 전체 애플리케이션의 통합 테스트에 사용됩니다.
webEnvironment 속성
- webEnvironment는 테스트 시 웹 환경을 어떻게 설정할지 지정합니다.
- SpringBootTest.WebEnvironment 열거형 값으로 설정하며, 다음과 같은 옵션이 있습니다:
1. MOCK
- (기본값) 서블릿 환경이 모의(Mock) 환경에서 실행됩니다.
- 실제 네트워크 포트를 사용하지 않고, 내장 서버를 시작하지 않습니다.
- 컨트롤러 테스트를 위한 MockMvc와 함께 사용됩니다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
2. RANDOM_PORT
- 내장 서버를 실제로 시작하고, 테스트 시 랜덤 포트를 할당합니다.
- @LocalServerPort로 할당된 포트를 주입받아 사용할 수 있습니다.
- API 엔드포인트를 실제 HTTP 요청으로 테스트할 때 사용됩니다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
3. DEFINED_PORT
- 내장 서버를 시작하지만, application.properties 또는 application.yml에 정의된 포트를 사용합니다.
- 예: server.port=8080이 설정되어 있다면 8080 포트를 사용합니다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
4. NONE
- 웹 환경을 비활성화합니다.
- 웹 서버 없이 순수한 Spring 컨텍스트만 로드합니다.
- 데이터 액세스 계층이나 서비스 계층 테스트에 적합합니다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
RANDOM_PORT의 특징
- 랜덤 포트 사용:
- 테스트 환경에서 실제로 서버를 실행하지만, 고정된 포트가 아닌 임의의 포트를 사용합니다.
- 포트 충돌을 방지하고, 병렬 테스트에 유리합니다.
- 내장 서버 실행:
- Spring Boot의 내장 Tomcat, Jetty, Undertow 등이 실행됩니다.
- 실제 애플리케이션처럼 HTTP 요청/응답을 테스트할 수 있습니다.
- @LocalServerPort와 함께 사용:
- 테스트 클래스에서 @LocalServerPort를 사용하여 할당된 랜덤 포트를 주입받을 수 있습니다.
- 이 포트를 통해 테스트 요청을 보낼 수 있습니다.
사용 예시
1. @SpringBootTest와 RANDOM_PORT를 사용한 테스트
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ProductApiTest {
@LocalServerPort
private int port;
@Test
void 상품등록() {
String baseUrl = "http://localhost:" + port + "/products";
// HTTP 요청 전송 및 응답 검증
RestAssured.given()
.contentType(MediaType.APPLICATION_JSON_VALUE)
.body(new AddProductRequest("상품명", 1000, DiscountPolicy.NONE))
.when()
.post(baseUrl)
.then()
.statusCode(HttpStatus.CREATED.value());
}
}
2. application.properties에서 고정 포트로 실행
고정 포트를 사용하려면 DEFINED_PORT와 함께 포트를 지정합니다:
server.port=8080
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
class ProductApiTest {
// 테스트 작성
}
추가로 본문 소스코드를 보면
ProductApiTest에는 @SpringBootTest 어노테이션이 안붙어있는데요
그 이유는 ProductApiTest가 ApiTest를 상속하고있고있고
ApiTest에 @SpringBootTest어노테이션이 붙어있기때문에
ProductApiTest에 어노테이션을 작성하지않은겁니다.
상위클래스에서 선언하면 하위클래스는 자동 적용됩니다.
장점
- 실제 서버 실행:
- 내장 서버를 실행하여 실제 HTTP 요청/응답을 테스트할 수 있습니다.
- RestAssured와 같은 도구를 사용해 API를 검증하기 적합합니다.
- 랜덤 포트 사용:
- 테스트 포트 충돌을 방지합니다.
- 병렬 테스트에 유리합니다.
- Spring Context 통합 테스트:
- 애플리케이션의 전체 흐름을 실제 서버 환경처럼 테스트할 수 있습니다.
적용 시 주의 사항
- 속도:
- 내장 서버를 시작하므로 MOCK 환경보다 느립니다.
- 속도가 중요한 경우 MockMvc를 사용한 테스트를 고려할 수 있습니다.
- 환경 설정:
- RANDOM_PORT를 사용할 경우, 테스트 포트를 확인하고 요청 URL을 동적으로 구성해야 합니다.
- 의존성 확인:
- 테스트 시 전체 애플리케이션 컨텍스트를 로드하기 때문에, 의존성 주입이 실패하면 테스트가 시작되지 않습니다.
3.ProductApiTest작성
class ProductApiTest extends ApiTest {
@Test
void 상품등록() {
final AddProductRequest request = 상품등록요청_생성();
// API 요청
final ExtractableResponse<Response> response = RestAssured.given().log().all()
.contentType(MediaType.APPLICATION_JSON_VALUE)
.body(request)
.when()
.post("/products")
.then()
.log().all().extract();
assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value());
}
private static AddProductRequest 상품등록요청_생성() {
final String name = "상품명";
final int price = 1000;
final DiscountPolicy discountPolicy = DiscountPolicy.NONE;
return new AddProductRequest(name, price, discountPolicy);
}
}
4. ProductService
package com.example.productorderservice.product;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
//비즈니스 로직
@RestController
@RequestMapping("/products")
class ProductService {
// 인터페이스
private final ProductPort productPort;
// 서비스 생성자(서비스 생성시 인터페이스를 받아야함)
ProductService(ProductPort productPort) {
this.productPort = productPort;
}
@PostMapping
public ResponseEntity<Void> addProduct(@RequestBody final AddProductRequest request) {
final Product product = new Product(request.name(), request.price(), request.discountPolicy());
//저장 로직 사용시 인터페이스 사용
productPort.save(product);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
}
코드 분석
클래스 선언
class ProductApiTest extends ApiTest
- ProductApiTest는 ApiTest를 상속받습니다.
- ApiTest에는 테스트 환경에서 필요한 기본 설정(예: @SpringBootTest)과 공통 메서드가 포함되어 있을 것입니다.
테스트 메서드: 상품등록()
@Test
void 상품등록() {
final AddProductRequest request = 상품등록요청_생성();
// API 요청
final ExtractableResponse<Response> response = RestAssured.given().log().all()
.contentType(MediaType.APPLICATION_JSON_VALUE)
.body(request)
.when()
.post("/products")
.then()
.log().all().extract();
assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value());
}
1. 테스트 메서드 선언
- @Test 어노테이션을 사용하여 JUnit5 테스트 메서드로 지정합니다.
- 메서드 이름 상품등록은 상품 등록 기능을 검증하는 테스트임을 나타냅니다.
2. 테스트 데이터 생성
final AddProductRequest request = 상품등록요청_생성();
- 상품등록요청_생성() 메서드를 호출하여 API 요청에 사용할 데이터를 생성합니다.
- 생성된 객체는 JSON 요청 본문(body)으로 전달됩니다.
3. API 요청 및 응답 처리
final ExtractableResponse<Response> response = RestAssured.given().log().all()
.contentType(MediaType.APPLICATION_JSON_VALUE)
.body(request)
.when()
.post("/products")
.then()
.log().all().extract();
- RestAssured.given(): RestAssured 요청 생성.
- .log().all(): 요청 정보(헤더, 본문 등)를 로그로 출력.
- .contentType(MediaType.APPLICATION_JSON_VALUE): 요청 본문의 콘텐츠 타입을 JSON으로 설정.
- .body(request): 요청 본문에 request 객체를 JSON으로 직렬화하여 추가.
- .when().post("/products"): POST 요청을 /products 엔드포인트로 전송.
- .then().log().all(): 응답 정보(상태 코드, 헤더, 본문 등)를 로그로 출력.
- .extract(): 응답을 ExtractableResponse로 추출하여 추가 데이터 검증 가능.
여기서 ExtractableResponse란?
ExtractableResponse의 역할
ExtractableResponse는 기본적으로 Response 객체에서 데이터를 쉽게 추출할 수 있도록 도와줍니다. 일반적으로 API 테스트에서 HTTP 응답의 상태 코드, 헤더, 바디 등의 데이터를 검증하거나 활용하기 위해 사용됩니다.
사용법
1. Response를 ExtractableResponse로 변환
RestAssured를 사용하여 HTTP 요청을 보낸 후, .extract() 메서드를 호출하면 ExtractableResponse로 변환됩니다.
ExtractableResponse<Response> response = RestAssured.given()
.contentType(MediaType.APPLICATION_JSON_VALUE)
.body(requestBody)
.when()
.post("/api/v1/resource")
.then()
.statusCode(HttpStatus.OK.value())
.extract();
2. 데이터 추출
ExtractableResponse는 HTTP 응답의 데이터에 쉽게 접근할 수 있는 메서드를 제공합니다.
- 상태 코드 가져오기
int statusCode = response.statusCode();
- 헤더 값 가져오기
String locationHeader = response.header("Location");
- JSON 응답 바디에서 값 추출
String name = response.jsonPath().getString("name");
int age = response.jsonPath().getInt("age");
- 응답 전체 바디 가져오기
String responseBody = response.body().asString();
예제: ExtractableResponse 활용한 API 테스트
상품 등록 테스트
@Test
void 상품등록() {
// 상품 등록 요청 생성
AddProductRequest request = new AddProductRequest("상품명", 1000, DiscountPolicy.NONE);
// API 요청
ExtractableResponse<Response> response = RestAssured.given().log().all()
.contentType(MediaType.APPLICATION_JSON_VALUE)
.body(request)
.when()
.post("/product")
.then()
.statusCode(HttpStatus.CREATED.value()) // 응답 상태 코드 검증
.extract(); // 응답 추출
// 응답 데이터 검증
String location = response.header("Location");
assertThat(location).isNotBlank();
}
상품 조회 테스트
@Test
void 상품조회() {
// API 요청
ExtractableResponse<Response> response = RestAssured.given().log().all()
.when()
.get("/product/1")
.then()
.statusCode(HttpStatus.OK.value()) // 상태 코드 검증
.extract();
// 응답 데이터 검증
String name = response.jsonPath().getString("name");
int price = response.jsonPath().getInt("price");
assertThat(name).isEqualTo("상품명");
assertThat(price).isEqualTo(1000);
}
ExtractableResponse를 사용하는 이유
- 응답 데이터 추출이 용이함:
- JSON 응답 바디의 특정 데이터를 간단히 추출 가능 (jsonPath()).
- 상태 코드, 헤더 등도
비교
객체 | 주로 사용하는 상황 | 주요 기능 |
ExtractableResponse | RestAssured 기반 API 테스트 | JSON 데이터 추출, 상태 코드/헤더 검증 편리 |
HttpResponse | 표준 Java HTTP 클라이언트 사용 | Java 표준 API, 응답 본문과 상태 코드 제공 |
ResponseEntity | Spring 애플리케이션 개발 | 상태 코드, 헤더, 본문 전체 관리 가능 |
4. 응답 검증
assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value());
- 응답 상태 코드가 **201(CREATED)**인지 검증합니다.
- **assertThat**은 AssertJ 라이브러리를 사용한 검증 메서드입니다.
참고로 HttpStatus란?
HttpStatus란?
- Spring Framework의 org.springframework.http.HttpStatus 열거형(Enum)입니다.
- HTTP 상태 코드를 읽기 쉽고 명확하게 표현하기 위해 사용됩니다.
- 각 상태 코드는 HTTP 프로토콜에서 정의된 상태를 나타냅니다.
- 예: 200 OK, 201 Created, 404 Not Found, 등
CREATED
- CREATED는 HTTP 상태 코드 201을 나타냅니다.
- 이 상태 코드는 서버가 요청을 성공적으로 처리하고 새로운 리소스를 생성했음을 의미합니다.
- 예:
- POST 요청으로 데이터를 생성할 때 응답으로 201 Created를 반환합니다.
value()
- value() 메서드는 해당 HttpStatus 열거형의 정수 값(숫자 상태 코드)을 반환합니다.
- 즉, HttpStatus.CREATED.value()는 201을 반환합니다.
사용 예시
1. 컨트롤러에서 사용
@RestController
@RequestMapping("/api/products")
public class ProductController {
@PostMapping
public ResponseEntity<Void> addProduct(@RequestBody AddProductRequest request) {
// 상품 추가 로직 수행
return ResponseEntity.status(HttpStatus.CREATED).build();
}
}
- 이 코드는 POST 요청을 처리한 후 201 Created 상태 코드로 응답합니다.
2. 테스트에서 사용
@Test
void 상품등록_응답확인() {
ExtractableResponse<Response> response = RestAssured.given()
.contentType(MediaType.APPLICATION_JSON_VALUE)
.body(new AddProductRequest("상품명", 1000, DiscountPolicy.NONE))
.when()
.post("/products")
.then()
.extract();
assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value());
}
- 테스트에서 응답의 상태 코드가 201인지 확인합니다.
주요 HTTP 상태 코드와 HttpStatus
상태코드 | 설명 | HttpStatus 열거형 |
200 | 요청이 성공했습니다. | HttpStatus.OK |
201 | 새로운 리소스가 생성되었습니다. | HttpStatus.CREATED |
400 | 잘못된 요청입니다. | HttpStatus.BAD_REQUEST |
401 | 인증이 필요합니다. | HttpStatus.UNAUTHORIZED |
404 | 리소스를 찾을 수 없습니다. | HttpStatus.NOT_FOUND |
500 | 서버 내부 오류가 발생했습니다. | HttpStatus.INTERNAL_SERVER_ERROR |
왜 사용하나요?
- 가독성:
- 숫자 상태 코드(201, 404, 500)를 직접 사용하는 것보다 의미를 명확히 전달합니다.
- 예: HttpStatus.CREATED는 상태 코드 201이 의미하는 바를 쉽게 이해할 수 있습니다.
- 타입 안정성:
- 숫자 값(201) 대신 Enum 타입을 사용하므로 컴파일 타임에 상태 코드 관련 오류를 방지할 수 있습니다.
- 유지보수성:
- 상태 코드를 수정하거나 추가할 때 코드 전체를 쉽게 업데이트할 수 있습니다.
도우미 메서드: 상품등록요청_생성()
private static AddProductRequest 상품등록요청_생성() {
final String name = "상품명";
final int price = 1000;
final DiscountPolicy discountPolicy = DiscountPolicy.NONE;
return new AddProductRequest(name, price, discountPolicy);
}
- 테스트에서 사용할 상품 등록 요청 데이터를 생성합니다.
- 상품 이름, 가격, 할인 정책(여기서는 NONE)을 설정합니다.
- 생성된 데이터는 AddProductRequest 객체로 반환되며, 이는 JSON 요청 본문으로 사용됩니다.
API 동작 흐름
- 테스트 데이터 생성:
- AddProductRequest 객체를 생성하여 요청 본문 데이터를 준비합니다.
- POST 요청 전송:
- /products 엔드포인트로 상품 등록 API를 호출합니다.
- JSON 데이터를 POST 요청의 본문으로 전달합니다.
- 응답 검증:
- 서버가 반환한 응답의 상태 코드가 **201(CREATED)**인지 확인합니다.
- 상태 코드가 일치하지 않으면 테스트는 실패합니다.
실행결과
기대한 201로 응답이 돌아오는것을 확인할 수 있습니다.
리팩토링
테스트를 통과했기때문에 코드를 리팩토링 해줍니다.
response만 영역잡고
ctrl + alt + m [윈도우]
또는
cmd +option+m [맥]
을 눌러 메소드로 변경해줍니다.
그리고 상품등록요청으로 이름을 변경해줍니다
그리고 reponse변수명에 커서를 둔 다음
ctrl + alt + n 을 눌러
인라인 메소드로 변경해줍니다
그리고 request와 response는 var타입으로 변경해줍니다
최종
package com.example.productorderservice.product;
import com.example.productorderservice.ApiTest;
import io.restassured.RestAssured;
import io.restassured.response.ExtractableResponse;
import io.restassured.response.Response;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat;
class ProductApiTest extends ApiTest {
@Test
void 상품등록() {
final var request = 상품등록요청_생성();
// API 요청
final var response = 상풍등록요청(request);
assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value());
}
private static ExtractableResponse<Response> 상풍등록요청(final AddProductRequest request) {
return RestAssured.given().log().all()
.contentType(MediaType.APPLICATION_JSON_VALUE)
.body(request)
.when()
.post("/products")
.then()
.log().all().extract();
}
private static AddProductRequest 상품등록요청_생성() {
final String name = "상품명";
final int price = 1000;
final DiscountPolicy discountPolicy = DiscountPolicy.NONE;
return new AddProductRequest(name, price, discountPolicy);
}
}
'서버&백엔드 > 🔥 JAVA' 카테고리의 다른 글
Java 병렬 스트림(Parallel Stream) 사용 시 예상되는 문제와 해결 방법 (0) | 2025.02.03 |
---|---|
JAVA | var 이란? (0) | 2024.12.22 |
Java | TDD시 사용되는 Assert (0) | 2024.12.19 |
JAVA | enum사용법 (0) | 2024.12.19 |
의존성 역전 원칙 (DIP) (0) | 2024.12.19 |