본문 바로가기

Spring Boot/LEARN

Spring Boot Test 하기 (1)

(모든 전체 코드는 github에 있습니다. )


Spring boot에서는 다양한 어노테이션 등을 이용해서 테스트를 쉽게 할 수 있도록 도와줍니다.

첫번째 포스팅에서는 Spring 기반의 Application 어떻게 이루어져있고 간단한 Rest API 를 만들어 보겠습니다.


Overview

Spring기반의 RESTFul Web Service는 아래와 같이 표현될 수 있습니다.(HandlerMapping 등은 생략)

Spring layer

tests_01_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의 파란색 부분이고 각 구간에 대해서 어떻게 테스트할 수 있는지 살펴보겠습니다.

위의 그림을 다시 시퀀스 다이어그램으로 나타내면 아래와 같이 표현할 수 있습니다.

tests_02_sequence

우선 테스트를 위한 예제 서버를 만들어보고 Spring에서 제공하는 @WebMvcTest, @DataJpaTest, @RestClientTest등의

어노테이션을 이용해서 필요한 구간만 테스트할 수 있는 테스트 슬라이스에 대하여 살펴보겠습니다.


Application 구성

Note : 테스트를 위한 Application이므로 예외처리 등은 모두 생략하였습니다 :)

예제로 작성할 서버는 Article에 대한 CRUD API이고 각각의 ArticleAuthor 정보도

제공하기 위해 외부 서비스인 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 생성/조회에서 사용됩니다.

  1. Article 생성 시 Account Service(Remote Service)에게 GET /v1/account/me를 호출하여 Profile 정보를 조회한다.
  2. 모든 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를 그대로 이용하고 있으며 findBySlugdeleteBySlug

추가 하였습니다.


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

다음 글에서는 위에서 살펴본 시퀀스다이어그램에서 테스트 슬라이스 구간을 나누고

테스팅하는 방법에 대해서 포스팅하겠습니다. 감사합니다!