(모든 전체 코드는 github에 있습니다. )
Spring boot에서는 다양한 어노테이션 등을 이용해서 테스트를 쉽게 할 수 있도록 도와줍니다.
첫번째 포스팅에서는 Spring 기반의 Application 어떻게 이루어져있고 간단한 Rest API 를 만들어 보겠습니다.
Overview
Spring기반의 RESTFul Web Service는 아래와 같이 표현될 수 있습니다.(HandlerMapping 등은 생략)
Spring layer
(출처: https://terasolunaorg.github.io/guideline/5.0.1.RELEASE/en/ArchitectureInDetail/REST.html)
- (1) : User -> WebService로 HTTP POST 요청(Create)
- (2) : JSON 포맷의 Request Body를 읽어서 Resource Object로 변환
- (3) : Resource Object의 Validation 체크
- (4) : 해당 Controller의 메소드 call
- (5) : Controller -> Service call
- (6) : Service -> Repository call
- (7) : Repository -> Database Create
- (8) : Service -> Remote Service call
- (9) : Controller에서 반환된 Object를 JSON 포맷으로 response body에 포함
- (10) : Server -> User HTTP Response
즉 개발자가 작성하는 부분은 Application Layer의 파란색 부분이고 각 구간에 대해서 어떻게 테스트할 수 있는지 살펴보겠습니다.
위의 그림을 다시 시퀀스 다이어그램으로 나타내면 아래와 같이 표현할 수 있습니다.
우선 테스트를 위한 예제 서버를 만들어보고 Spring에서 제공하는 @WebMvcTest
, @DataJpaTest
, @RestClientTest
등의
어노테이션을 이용해서 필요한 구간만 테스트할 수 있는 테스트 슬라이스에 대하여 살펴보겠습니다.
Application 구성
Note : 테스트를 위한 Application이므로 예외처리 등은 모두 생략하였습니다 :)
예제로 작성할 서버는 Article
에 대한 CRUD API이고 각각의 Article
에 Author
정보도
제공하기 위해 외부 서비스인 Account
API도 만들어 보겠습니다.
(모듈 구성을 하나로 하기 위해 다른 패키지의 Controller를 Mock Server로 이용합니다)
Article API
Endpoint | Description |
---|---|
GET /v1/articles?page=1&size=10&sort=id,DESC | article page 조회 |
POST /v1/article | article 생성 |
GET /v1/article/{slug} | article 조회 |
DELETE /v1/article/{slug} | article 삭제 |
Account API
Endpoint | Description |
---|---|
GET /v1/account/me | 현재 사용자 Profile 조회 |
GET /v1/account/profile/{accountId} | 해당 Account Profile 조회 |
Account API는 아래와 같이 Article
생성/조회에서 사용됩니다.
- Article 생성 시 Account Service(Remote Service)에게
GET /v1/account/me
를 호출하여 Profile 정보를 조회한다. - 모든 Article 조회 시 Account Service(Remote Service)에게
GET /v1/account/{accountId
를 호출하여 Author 정보를 추가한다.
Repository 계층 구현
ArticleEntity
@Entity(name = "Article")
@Getter
@Setter(AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ArticleEntity extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "article_id")
private Long id;
@Column(name = "slug", unique = true)
private String slug;
@Column(name = "title")
private String title;
@Column(name = "description")
private String description;
@Column(name = "author_id")
private String authorId;
/**
* Create a new {@link ArticleEntity} given args
*/
public static ArticleEntity createArticle(String title, String description, String authorId) {
final ArticleEntity entity = new ArticleEntity();
entity.setSlug(toSlug(title));
entity.setTitle(title);
entity.setDescription(description);
entity.setAuthorId(authorId);
return entity;
}
private static String toSlug(String title) {
return title.toLowerCase().replaceAll("[\\&|\\uFE30-\\uFFA0|\\’|\\”|\\s\\?\\,\\.]+", "-");
}
}
ArticleEntity
는 id, slug, title, description, authorId 값을 저장합니다.
Lombok을 이용하고 있고 Setter 및 Construct 메소드에 대해서는 모두 Protected level로 설정하였습니다.
ArticleRepository
public interface ArticleRepository extends JpaRepository<ArticleEntity, Long> {
/**
* Returns a optional of {@link ArticleEntity} given slug value
*/
Optional<ArticleEntity> findBySlug(String slug);
/**
* Delete a article with given slug
*/
@Modifying
@Query("DELETE FROM Article a WHERE a.slug=:slug")
Integer deleteBySlug(@Param("slug") String slug);
}
Spring data jpa에서 제공하는 JpaRepository
를 그대로 이용하고 있으며 findBySlug
및 deleteBySlug
만
추가 하였습니다.
Service
ArticleResource (DTO)
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ArticleResource {
private String slug;
@NotEmpty
private String title;
@NotEmpty
private String description;
private AuthorResource author;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@JsonProperty
public String getSlug() {
return slug;
}
@JsonIgnore
public void setSlug(String slug) {
this.slug = slug;
}
@JsonIgnore
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
@JsonProperty
public LocalDateTime getCreatedAt() {
return createdAt;
}
@JsonIgnore
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
@JsonProperty
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
}
Article
에 대한 DTO로 이용되는 클래스이며, 생성 요청 시 slug 값을 무시하기 위해서 @JsonProperty
, @JsonIgnore
를
이용하고 있습니다.
ArticleAssembler (Entity -> DTO 변환)
public final class ArticleAssembler {
/**
* Convert {@link ArticleEntity} and {@link AccountProfile} to {@link ArticleResource}
*/
public static ArticleResource toResource(ArticleEntity entity, AccountProfile profile) {
return ArticleResource.builder()
.slug(entity.getSlug())
.title(entity.getTitle())
.author(AuthorResource.builder()
.authorId(entity.getAuthorId())
.name(profile.getName())
.bio(profile.getBio())
.build())
.description(entity.getDescription())
.createdAt(entity.getCreatedAt())
.updatedAt(entity.getUpdatedAt())
.build();
}
/**
* Convert {@link ArticleResource} and {@link AccountProfile} to {@link ArticleEntity}
*/
public static ArticleEntity toEntity(ArticleResource resource, AccountProfile profile) {
return ArticleEntity.createArticle(resource.getTitle(), resource.getDescription(), profile.getAccountId());
}
private ArticleAssembler() {
}
}
위와 같이 Entity
<-> DTO
간 변환을 해주는 ArticleAssembler
를 추가해주었습니다.
ArticleService
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ArticleService {
private final ArticleRepository articleRepository;
private final AccountRemoteService accountService;
private final CacheManager cacheManager;
public Page<ArticleResource> getArticles(Pageable pageable) {
Page<ArticleEntity> page = articleRepository.findAll(pageable);
if (!page.hasContent()) {
return Page.empty();
}
final List<ArticleResource> resources = page.getContent().stream().map(entity -> {
final AccountProfile profile = getAccountProfileById(
entity.getAuthorId());
return ArticleAssembler.toResource(entity, profile);
}).collect(Collectors.toList());
return new PageImpl<>(resources, pageable, page.getTotalElements());
}
@Transactional
public ArticleResource saveArticle(ArticleResource articleResource) {
final AccountProfile profile = accountService.getAuthenticatedAccount();
final ArticleEntity saved = articleRepository.save(ArticleAssembler.toEntity(articleResource, profile));
final ArticleResource resource = ArticleAssembler.toResource(saved, profile);
try {
final Cache cache = cacheManager.getCache("article");
if (cache != null) {
cache.put(resource.getSlug(), resource);
}
} catch (Exception ignored) {
logger.warn("failed to put article. reason: {}", ignored.toString());
}
return resource;
}
@Cacheable(value = "article", key = "#slug")
public Optional<ArticleResource> getArticleBySlug(String slug) {
Optional<ArticleEntity> entityOptional = articleRepository.findBySlug(slug);
if (!entityOptional.isPresent()) {
return Optional.empty();
}
final ArticleEntity entity = entityOptional.get();
final AccountProfile profile = getAccountProfileById(entity.getAuthorId());
return Optional.of(ArticleAssembler.toResource(entity, profile));
}
@CacheEvict(value = "article", key = "#slug")
@Transactional
public Integer deleteArticleBySlug(String slug) {
return articleRepository.deleteBySlug(slug);
}
private AccountProfile getAccountProfileById(String accountId) {
return accountService.getAccountProfileById(accountId);
}
}
다음으로는 Service 계층 구현인데요, @Service
어노테이션을 이용하고 있고
@Transactional(readOnly = true)
을 클래스에 추가해줘서 모든 메소드에 기본으로 적용하도록 하였습니다.
구현 메소드들은 Article
에 대하여 전체 조회 / Slug로 조회 / Slug로 삭제 기능이 있습니다.
다음은 Account API서버와 통신 할 AccountRemoteService
를 만들어 보겠습니다.
AccountProfile
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AccountProfile {
private String accountId;
private String name;
private String bio;
}
AccountRemoteService
@Slf4j
@Service
public class AccountRemoteService {
private final String endpoint;
private final RestTemplate restTemplate;
@Autowired
public AccountRemoteService(RestTemplate restTemplate,
@Value("${account-service.host:account-service}") String endpoint) {
logger.info("## initialize account service. host: {} / restTemplate : {}", endpoint,
restTemplate.getClass().getName());
this.endpoint = endpoint;
this.restTemplate = restTemplate;
}
/**
* Call {endpoint}/v1/account/me to get current authenticated account
*/
public AccountProfile getAuthenticatedAccount() {
final URI uri = UriComponentsBuilder.fromHttpUrl(endpoint)
.pathSegment("v1", "account", "me")
.build()
.toUri();
return restTemplate.getForObject(uri, AccountProfile.class);
}
/**
* Call {endpoint}/v1/account/profile/{accountId} to get given account's profile
*/
public AccountProfile getAccountProfileById(String accountId) {
final URI uri = UriComponentsBuilder.fromHttpUrl(endpoint)
.pathSegment("v1", "account", "profile", accountId)
.build()
.toUri();
return restTemplate.getForObject(uri, AccountProfile.class);
}
}
RestTemplate
을 이용하여 (1) 현재 사용자 조회 / (2) Account ID로 조회하는 기능을 추가하였습니다.
Controller
ArticleController
@Slf4j
@RestController
@RequiredArgsConstructor
public class ArticleController {
private final ArticleService articleService;
/**
* Get articles given page request
*/
@GetMapping("/v1/articles")
public PagedResource<ArticleResource> getArticles(
@PageableDefault(sort = { "id" }, direction = Sort.Direction.DESC, size = 5) Pageable pageable) {
logger.info("## Request articles. pageable : {}", pageable);
final Page<ArticleResource> page = articleService.getArticles(pageable);
return PagedResource.toPagedResource(URI.create("/v1/articles"), page);
}
/**
* Save a new article
*/
@PostMapping("/v1/article")
public ResponseEntity saveArticle(@Valid @RequestBody ArticleResource article, Errors errors) {
if (errors.hasErrors()) {
return ResponseEntity.badRequest().body(errors);
}
return ResponseEntity.ok(articleService.saveArticle(article));
}
/**
* Get a article by slug
*/
@GetMapping("/v1/article/{slug}")
public ResponseEntity getArticle(@PathVariable("slug") String slug) {
Optional<ArticleResource> articleOptional = articleService.getArticleBySlug(slug);
if (!articleOptional.isPresent()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(articleOptional.get());
}
@DeleteMapping("/v1/article/{slug}")
public ResponseEntity deleteArticle(@PathVariable("slug") String slug) {
final Integer deleted = articleService.deleteArticleBySlug(slug);
if (deleted == 0) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok().build();
}
}
마지막으로 Controller 부분인데요 위에서 정의한 API 스펙들을 간단하게 구현하였습니다.
http test
intellij에서 제공하는 .http를 이용하여 간단하게 테스트를 진행해보겠습니다.
### Article 생성
POST http://localhost:3000/v1/article
Content-Type: application/json
{
"title": "title-1",
"description": "description-1"
}
HTTP/1.1 200
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 19 Sep 2020 06:53:19 GMT
Keep-Alive: timeout=60
Connection: keep-alive
{
"slug": "title-1", "title": "title-1", "description": "description-1",
"author": { "authorId": "7545000b-fadd-4e74-95f8-c763256c6597", "name": "user5", "bio": "user bio5" },
"createdAt": "2020-09-19T15:53:19.763", "updatedAt": "2020-09-19T15:53:19.763"
}
### Article 조회
GET http://localhost:3000/v1/article/title-1
HTTP/1.1 200
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 19 Sep 2020 06:53:56 GMT
Keep-Alive: timeout=60
Connection: keep-alive
{
"slug": "title-1", "title": "title-1", "description": "description-1",
"author": { "authorId": "7545000b-fadd-4e74-95f8-c763256c6597", "name": "user5", "bio": "user bio5" },
"createdAt": "2020-09-19T15:53:19.763", "updatedAt": "2020-09-19T15:53:19.763"
}
### Article 리스트 조회
GET http://localhost:3000/v1/articles
HTTP/1.1 200
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 19 Sep 2020 06:54:24 GMT
Keep-Alive: timeout=60
Connection: keep-alive
{
"content": [
{
"slug": "title-1", "title": "title-1", "description": "description-1",
"author": { "authorId": "7545000b-fadd-4e74-95f8-c763256c6597", "name": "user5", "bio": "user bio5" },
"createdAt": "2020-09-19T15:53:19.763", "updatedAt": "2020-09-19T15:53:19.763"
}
], "pages": { "first": "/v1/articles?page=0&size=5&sort=id,DESC", "prev": "", "last": "", "next": "" }
}
### Article 삭제
DELETE http://localhost:3000/v1/article/title-1
HTTP/1.1 200
Content-Length: 0
Date: Sat, 19 Sep 2020 06:54:48 GMT
Keep-Alive: timeout=60
Connection: keep-alive
다음 글에서는 위에서 살펴본 시퀀스다이어그램에서 테스트 슬라이스 구간을 나누고
테스팅하는 방법에 대해서 포스팅하겠습니다. 감사합니다!
'Spring Boot > LEARN' 카테고리의 다른 글
Spring Security + OAuth2(JDBC) + Swagger 서버 구축하기 (0) | 2019.12.25 |
---|---|
Springboot의 Auto configuration 살펴보기 (0) | 2019.04.01 |