티스토리 뷰

Component Tests with Microservices

1. Component Tests of Microservices - Microservices Patterns: With Examples in Java, Chris Richardson
2. https://martinfowler.com/articles/microservice-testing/#testing-contract-introduction - Martin Fowler

A component test limits the scope of the exercised software to a portion of the system under test(SUT), manipulating the system through internal code interfaces and using test doubles to isolate the code under test from other components. - Martin Fowler

컴포넌트 테스트는 소프트웨어의 테스트 범위를 테스트 대상 소프트웨어로 제한하고 내부 코드 인터페이스를 통해 Dependancy를 갖고 구현은 Test Double을 사용하여 테스트 중인 코드를 다른 구성 요소로부터 분리한다. 테스트 대상 외의 Dependency를 격리하기 위해 Test Double을 사용한다.  즉 외부환경의 의존성을 Stubbing한 상태로 Acceptance Tests(인수테스트)를 작성 할 수 있다.

Test Double

  • Test Double이란 xUnit Test Patterns의 저자인 제라드 메스자로스(Gerard Meszaros)가 만든 용어로 테스트를 진행하기 어려운 경우 이를 대신해 테스트를 진행할 수 있도록 만들어주는 객체를 말한다. 스턴트 더블(영화 촬영에서 말하는 스턴트 대역 배우)에서 아이디어를 얻어서 만든 용어이다. (http://xunitpatterns.com/Test Double.html)
  • SUT(System Under Test)는 모든 테스트 대상 시스템을 지칭하는 용어이며 Test Double, DOC(depended-on component)를 포함 할 수 있다. (http://xunitpatterns.com/SUT.html)
  • Fixture란 테스트 실행을 위해 베이스라인으로서 사용되는 객체들의 고정된 상태이다. 테스트 픽스처의 목적은 고정된 환경에서 테스트할 수 있음을 보장하기 위함이다. 예를들어 외부 Database 또는 외부 서비스 Producer를 Consuming할때 연결이 실패하거나 환경이 준비되지 않을경우 테스트 역시 실패하게 됨으로 고정된 환경을 제공받을 수 없다.

  • Test Double을 사용하는 목적
    • 테스트 대상 코드를 격리한다.
    • 테스트 속도를 개선한다.
    • 예측 불가능한 실행 요소를 제거한다.
    • 특수한 상황을 시뮬레이션한다.
    • 감춰진 정보를 얻어낸다.
  • Test Double의 종류
    • Dummy : 가장 기본적인 Test Double로, 객체가 필요하지만 내부 기능이 필요하지는 않을 때 사용하게 된다. 예를 들어 함수호출 시 전달하는 파라미터와 함께 호출되지만 내부적으로 아무런 동작이 일어나지 않는다.
    • Stub : 테스트에서 호출된 요청에 대해 미리 준비해둔 응답값을 제공한다. 예를들어 payment 객체의 pay 메서드를 테스트 할 경우 pay에 대한 성공 유무 결과를 미리 정의해 둔다.
    • Spy : 기본적으로 Stub의 역할과 추가적으로 상태에대한 기록등의 정보를 가진다. 예를들어 payment의 pay메서드 호출 시 정의해둔 응답결과 외에 pay함수의 호출 횟 수 등의 추가정보를 가질 수 있다.
    • Fake : 실제 사용되는 객체는 아니지만 실제 객체와 동일한 동작을 구현 해 둔 객체이다.
    • Mock : Stub은 상태(기대 값)를 검증하기위한 테스트 방법이라면 Mock은 행위를 검증하기 위해 가짜 객체를 생성하여 개체의 기대행동 및 결과등을 mocking하여 테스트하는 방법이다.(행위기반테스트와 상태기반 테스트의 차이 - https://martinfowler.com/articles/mocksArentStubs.html)

 

Writing Component Tests Using Test Double

Test Double을 이용하여 테스트 격리 환경을 만드는 것이 중요하다. 위에서 언급했듯 테스트 비용을 줄이고, 속도를 빠르게 올리고, 예측불가능한 실행요소를 제거하는것이 핵심이며, 외부 Dependency에 의해 테스트 환경구성에 실패한다면 테스트를 점점 기피하게되고 테스트 문화 정착에 실패하게 된다. 

Stubbing JpaRepositories with H2DB

H2 is a relational database management system written in Java. It can be embedded in Java applications or run in client-server mode. The software is available as open source software Mozilla Public License 2.0 or the original Eclipse Public License. Wikipedia

JpaRepositories를 Stubbing하기위해 H2DB를 주로 사용한다. 다양한 DB와의 호환성 모드(Oracle, MySQL, MariaDB, MSSql, PostgreSQL, Ignite..)를 제공하고있기 때문에 각 DB의 특성에 맞게 동작합니다.

테스트를 위한 Dependency를 추가합니다.

build.gradle

dependencies {
		runtimeOnly('com.h2database:h2')
}

 

 

테스트 Profile에 h2db resource를 작성합니다. (profile=test 에서만 동작)

resources/application.yaml

spring:
  profiles:
    active: test
  jpa:
    hibernate:
      dialect: org.hibernate.dialect.H2Dialect
      ddl-auto: create
    properties:
      hibernate:
        show_sql: true
        format_sql: true

  datasource:
    url: jdbc:h2:mem:test;MODE=MySQL
    username: sa
    password:
    driver-class-name: org.h2.Driver

 

Stubbing된 Repository가 정상적으로 동작하는지 테스트 코드를 작성한다.

test/CouponRepositoryTest.java

/*
 * Copyright 2021 ROCKSEA. All rights Reserved.
 * ROCKSEA PROPRIETARY/CONFIDENTIAL. Use is subject to license terms.
 */
package kr.co.sample.coupon.domain.repository;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.util.Optional;

import javax.persistence.EntityNotFoundException;

import org.junit.jupiter.api.*;
import org.junit.jupiter.api.TestInstance.Lifecycle;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.test.annotation.Rollback;
import org.springframework.test.context.ActiveProfiles;

import kr.co.sample.coupon.domain.aggregate.Coupon;
import kr.co.sample.coupon.domain.entity.BasicCoupon;
import kr.co.sample.coupon.domain.query.exception.CouponNotFoundException;
import kr.co.sample.coupon.domain.vo.CouponType;
import kr.co.sample.coupon.domain.vo.DiscountType;

@DataJpaTest
@EnableJpaRepositories(basePackages = {"kr.co.sample.coupon.*"})
@EntityScan("kr.co.sample.coupon")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@TestInstance(Lifecycle.PER_CLASS)
@DisplayName("CouponRepository Tests")
public class CouponRepositoryTest {
    @Autowired private CouponRepository couponRepository;

    Coupon basicCoupon;

    @BeforeAll
    void setup() {
        basicCoupon =
                BasicCoupon.builder()
                        .id(1)
                        .name("BasicCoupon")
                        .couponType(CouponType.BASIC)
                        .discountType(DiscountType.AMOUNT)
                        .amount(1000)
                        .build();
    }

    @Test
    @Order(1)
    @Rollback(false)
    @DisplayName("베이직쿠폰이 입력되어야한다")
    public void basicCouponShouldBeInserted() {
        Coupon savedCoupon = couponRepository.save(basicCoupon);
        assertThat(basicCoupon).isNotNull().isEqualTo(savedCoupon);
    }

    @Test
    @Order(2)
    @Rollback(false)
    @DisplayName("베이직쿠폰이 조회가 성공해야한다")
    public void basicCouponShouldBeFound() {
        Coupon coupon =
                couponRepository
                        .findById(1)
                        .orElseThrow(() -> new CouponNotFoundException("쿠폰이 존재하지 않습니다."));
        assertThat(coupon).isEqualTo(basicCoupon);
    }

    @Test
    @Order(3)
    @Rollback(false)
    @DisplayName("베이직쿠폰이 삭제되어야한다")
    public void basicCouponShouldBeDeleted() {
        couponRepository.delete(basicCoupon);
        Optional<Coupon> coupon = couponRepository.findById(1);
        assertThat(coupon.isEmpty()).isTrue();
    }

    @Test
    @Order(4)
    @Rollback(false)
    @DisplayName("베이직쿠폰이 조회 시 예외가 발생해야한다")
    public void basicCouponShouldNotBeFound() {
        assertThrows(
                EntityNotFoundException.class,
                () -> {
                    Coupon deletedCoupon = couponRepository.getById(basicCoupon.getId());
                    assertThat(deletedCoupon).isNull();
                });
    }
}

 

Test Results

 

Stubbing a Producer Using WireMock Stub Server

외부 Producer의 RestAPI dependency를 Stubbing 하기위한 방법을 기술한다.

SUT인 Producer 서비스를 호출하기 위해선 contract을 작성한 뒤 자동 생성된 stub파일을 통해 Producer의 Rest API 응답/요청값을 Stubbing 할 수 있다. (https://docs.pact.io/, https://spring.io/guides/gs/contract-rest/)

Rest API Server를 Stubbing하기 위해 아래 Dependency를 추가한다.

build.gradle

dependencies {
	testImplementation('org.springframework.cloud:spring-cloud-starter-contract-stub-runner')
}

RestAPI 호출 시 기대하는 값을 stub json파일로 정의한다. (spring-cloud-contract을 통해 contract을 작성하면 아래의 stub파일을 자동으로 생성하고 관리할 수 있다.

test/resources/stubs/member-200.json

{
  "request": {
    "method": "GET",
    "url": "/member/v1/members/1"
  },
  "response": {
    "status": 200,
    "body": "{\\"id\\":1, \\"name\\":\\"Steve\\", \\"age\\":30}",
    "headers": {
      "Content-Type": "application/json",
      "X-WireMock-Who-Am-I": "API_V1"
    }
  }
}

 

test/resources/stubs/member-404.json

{
  "request": {
    "method": "GET",
    "url": "/member/v1/members/2"
  },
  "response": {
    "status": 404,
    "body": "",
    "headers": {
      "Content-Type": "application/json",
      "X-WireMock-Who-Am-I": "API_V1"
    }
  }
}

 

Consuming REST API Using Feign Client

Rest API를 호출하기 위해 OpenFeign dependency를 추가한다.

build.gradle

dependencies {
	implementation('org.springframework.cloud:spring-cloud-starter-openfeign')
}

FeignClient로 호출할 producer endpoint url을 정의한다

test/resources/application.yaml

service:
  member:
    endpoint: <http://localhost>:${wiremock.server.port}

FeignClient를 통해 호출할 member producer의 Interface를 정의한다

repository/MemberRepository.java

/*
 * Copyright 2021 ROCKSEA. All rights Reserved.
 * ROCKSEA PROPRIETARY/CONFIDENTIAL. Use is subject to license terms.
 */
package kr.co.sample.coupon.infrastructure;

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

import kr.co.sample.coupon.domain.vo.Member;

@FeignClient(name = "member", url = "${service.member.endpoint}")
public interface MemberRepository {
    @GetMapping("/member/v1/members/{id}")
    Member getMember(@PathVariable(name = "id") Integer id);
}

Stubbing된 Repository가 정상적으로 동작하는지 테스트 코드를 작성한다.

test/MemberRepositoryTest.java

/*
 * Copyright 2021 ROCKSEA. All rights Reserved.
 * ROCKSEA PROPRIETARY/CONFIDENTIAL. Use is subject to license terms.
 */
package kr.co.sample.coupon.infrastructure;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.cloud.contract.wiremock.AutoConfigureWireMock;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.cloud.openfeign.FeignAutoConfiguration;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;

import feign.FeignException;
import kr.co.sample.coupon.domain.vo.Member;

@WebMvcTest
@Import({FeignAutoConfiguration.class})
@EnableFeignClients(basePackages = "kr.co.sample.coupon.infrastructure")
@AutoConfigureWireMock(port = 0, stubs = "classpath:/stubs")
@DisplayName("MemberRepository Tests")
public class MemberRepositoryTest {
    @Autowired MemberRepository memberRepository;

    @Test
    @DisplayName("id값이 1인 회원 조회 시 성공해야한다")
    public void memberShouldBeFound() throws Exception {
        Member member = memberRepository.getMember(1);
        assertThat(member.getName()).isEqualTo("Steve");
        assertThat(member.getAge()).isEqualTo(30);
        System.out.println(member);
    }

    @Test
    @DisplayName("id값이 2인 회원 조회 시 NotFound 예외가 발생해야 한다")
    public void memberShouldNotBeFound() throws Exception {
        assertThrows(
                FeignException.NotFound.class,
                () -> {
                    Member member = memberRepository.getMember(2);
                });
    }
}

Test Results

 

Stubbing a Redis Using Embedded Redis Server

Redis 서버를 Stubbing하기 위해 Embedded Redis Server를 사용한다.

build.gradle

dependencies {
	testImplementation('it.ozimov:embedded-redis:0.7.3') {
		exclude group: "org.slf4j", module: "slf4j-simple"
	}
}

config/RedisConfig.java

/*
 * Copyright 2021 Code Gauntlet. All rights Reserved.
 * ROCKSEA PROPRIETARY/CONFIDENTIAL. Use is subject to license terms.
 */
package kr.co.sck.helloworld.config.redis;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;

import kr.co.sck.helloworld.domain.repository.IssuedCouponRepository;

@Configuration
@EnableRedisRepositories(basePackageClasses = {IssuedCouponRepository.class})
public class RedisConfig {
    @Bean
    public LettuceConnectionFactory redisConnectionFactory(
            @Value("${spring.redis.port}") int redisPort,
            @Value("${spring.redis.host}") String redisHost) {
        return new LettuceConnectionFactory(redisHost, redisPort);
    }

    @Bean
    public ObjectMapper redisObjectMapper() {
        var mapper = new ObjectMapper();
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE);
        mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);
        return mapper;
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory connectionFactory) {
        var redisSerializer = new GenericJackson2JsonRedisSerializer(redisObjectMapper());
        var stringSerializer = new StringRedisSerializer();
        var template = new RedisTemplate<String, Object>();
        template.setConnectionFactory(connectionFactory);
        template.setKeySerializer(stringSerializer);
        template.setValueSerializer(redisSerializer);
        template.setHashKeySerializer(stringSerializer);
        template.setHashValueSerializer(redisSerializer);
        return template;
    }
}

test/config/EmbeddedRedisConfig.java

/*
 * Copyright 2021 Code Gauntlet. All rights Reserved.
 * ROCKSEA PROPRIETARY/CONFIDENTIAL. Use is subject to license terms.
 */
package kr.co.sck.helloworld.config;

import java.io.IOException;

import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.TestConfiguration;

import redis.embedded.RedisServer;

@TestConfiguration
public class EmbeddedRedisConfig {

    @Value("${spring.redis.port}")
    private int redisPort;

    private RedisServer redisServer;

    @PostConstruct
    public void redisServer() throws IOException {
        redisServer = new RedisServer(redisPort);
        redisServer.start();
    }

    @PreDestroy
    public void stopRedis() {
        if (redisServer != null) {
            redisServer.stop();
        }
    }
}

test/repository/EmbeddedRedisConfig.java

/*
 * Copyright 2021 Code Gauntlet. All rights Reserved.
 * ROCKSEA PROPRIETARY/CONFIDENTIAL. Use is subject to license terms.
 */
package kr.co.sck.helloworld.repository;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.MethodOrderer.*;

import org.junit.jupiter.api.*;
import org.junit.jupiter.api.DisplayName;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Rollback;

import kr.co.sck.helloworld.config.EmbeddedRedisConfig;
import kr.co.sck.helloworld.config.redis.RedisConfig;
import kr.co.sck.helloworld.domain.aggregate.IssuedCoupon;
import kr.co.sck.helloworld.domain.exception.IssuedCouponNotFoundException;
import kr.co.sck.helloworld.domain.repository.IssuedCouponRepository;
import kr.co.sck.helloworld.domain.vo.Coupon;

@SpringBootTest(classes = {EmbeddedRedisConfig.class, RedisConfig.class})
@TestMethodOrder(OrderAnnotation.class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@DisplayName("IssuedCouponRepository Tests")
public class IssuedCouponRepositoryTest {
    @Autowired IssuedCouponRepository issuedCouponRepository;

    Coupon coupon;
    IssuedCoupon issuedCoupon;

    @BeforeAll
    public void setup() {
        coupon =
                Coupon.builder()
                        .id(1)
                        .couponType("BASIC_COUPON")
                        .discountType("AMOUNT")
                        .name("베이직쿠폰")
                        .amount(1000)
                        .build();
        issuedCoupon = IssuedCoupon.builder().id(1).memberId(1).name("회원가입쿠폰").coupon(coupon).build();
    }

    @Test
    @Order(1)
    @DisplayName("발급쿠폰 입력에 성공해야한다")
    public void issuedCoupon_should_be_added() {
        IssuedCoupon addedIssuedCoupon = issuedCouponRepository.save(issuedCoupon);
        assertThat(issuedCoupon).isEqualTo(addedIssuedCoupon);
    }

    @Test
    @Order(2)
    @DisplayName("발급쿠폰 조회에 성공해야한다")
    public void issuedCoupon_should_be_found() {
        IssuedCoupon issuedCoupon =
                issuedCouponRepository
                        .findById(1)
                        .orElseThrow(() -> new IssuedCouponNotFoundException("발급된 쿠폰을 찾을 수 없습니다"));
        assertThat(issuedCoupon).isEqualTo(this.issuedCoupon);
        System.out.println(issuedCoupon.getName());
        System.out.println(issuedCoupon.getCoupon().getName());
    }

    @Test
    @Order(3)
    @DisplayName("발급쿠폰 조회에 실패해야한다")
    public void issuedCoupon_should_occured_exception() {
        assertThrows(
                IssuedCouponNotFoundException.class,
                () -> {
                    IssuedCoupon issuedCoupon =
                            issuedCouponRepository
                                    .findById(2)
                                    .orElseThrow(() -> new IssuedCouponNotFoundException("발급된 쿠폰을 찾을 수 없습니다"));
                });
    }
}

 

Service Component Tests

외부 Dependency에대한 Integration Tests가 완료되면 독립된 환경에서 테스트 대상 Component를 검증하는 테스트를 수행 할 수 있다. 서비스 기능에 대한 Acceptance tests를 수행한다.

test/AddCouponContollerTest.java

/*
 * Copyright 2021 ROCKSEA. All rights Reserved.
 * ROCKSEA PROPRIETARY/CONFIDENTIAL. Use is subject to license terms.
 */
package kr.co.sample.coupon;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.cloud.contract.wiremock.AutoConfigureWireMock;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

import kr.co.sample.coupon.domain.query.CouponQueryResult;
import kr.co.sample.coupon.domain.vo.CouponType;
import kr.co.sample.coupon.domain.vo.DiscountType;
import kr.co.sample.coupon.domain.vo.Member;
import kr.co.sample.coupon.presentation.http.request.AddCouponParam;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@EnableAutoConfiguration
@ComponentScan("kr.co.sample.coupon")
@AutoConfigureWireMock(port = 0, stubs = "classpath:/stubs/**/*.json")
public class AddCouponScenarioTest {
    @LocalServerPort private int port;

    @Autowired private TestRestTemplate restTemplate;

    @Test
    public void shouldBeAddedCouponAndThenGetMember() throws Exception {
        AddCouponParam addCouponParam =
                AddCouponParam.builder()
                        .name("basic coupon")
                        .couponType(CouponType.BASIC)
                        .discountType(DiscountType.AMOUNT)
                        .amount(1000)
                        .build();

        // Add a Basic Coupon to H2
        ResponseEntity<Void> postResult =
                this.restTemplate.postForEntity(
                        String.format("<http://localhost>:%d/coupon", port), addCouponParam, Void.class);
        assertThat(postResult.getStatusCode()).isEqualTo(HttpStatus.CREATED);

        // Get a Added Coupon from H2
        ResponseEntity<CouponQueryResult> getResult =
                this.restTemplate.getForEntity(
                        String.format("<http://localhost>:%d/coupon/1", port), CouponQueryResult.class);
        assertThat(getResult.getStatusCode()).isEqualTo(HttpStatus.OK);
        System.out.println("### coupon result : " + getResult.getBody().getName());

        // Get a member from WireMock
        ResponseEntity<Member> getMemberResult =
                this.restTemplate.getForEntity(
                        String.format("<http://localhost>:%d/member/v1/members/1", port), Member.class);
        assertThat(getMemberResult.getStatusCode()).isEqualTo(HttpStatus.OK);
        System.out.println("### member result : " + getMemberResult.getBody().getName());
    }
}

 

Test Results
외부 Dependency를 없애고 H2DB, WireMockServer를 Stubbing한 Repository를 통해 값을 Add하고, Fetch 할 수 있다.

'Developer' 카테고리의 다른 글

How to use KEDA  (0) 2022.10.25
TrafficManagement with ISTIO on EKS  (0) 2022.09.26
Kubernetes Liveness & Readiness Probe with Spring boot  (0) 2022.07.26
Configure AWS Credentials For GitHub Actions with OIDC  (0) 2022.06.05
How to install Argocd  (0) 2022.04.25
댓글