Spring Projcect/계좌 관리 시스템 프로젝트

Chapter 07. 사전 준비

계란💕 2022. 8. 2. 11:29

 

 

7.1 전체 예제 소개

  - 계좌 시스템의 전반적인 구조와 기능

  - 스트링 부트 기반으로 진행.

  - 활용 기술: Spring Boot 2.6.xx, JDK 11, Gradle, Junit5, H2 Database(자바 기반의 오픈 소스 관계형 데이터베이스 관리시스템), JPA, Redis, Mockito, lombok

 

 

  제공하는 기능 (API)

  • 계좌(account) 관련기능 (생성, 해지, 확인)
  • 거래(Transaction) 관련 기능(잔액 사용, 잔액 사용 취소- 거래 취소, 거래 확인)

 

7.2 프로젝트 생성 및 의존성(dependency) 추가

 

  추가된 의존성 종류

  • spring boot starter data jpa: JPA(Java Persistence API)를 지원하기 위한 의존성
  • spring boot starter validation: bean validation 지원을 위한 의존성
  • spring boot starter web: Spring을 활용한 Web(API) 개발을 위한 의존성
  • org.redisson:redisson: redisson이라는 redis를 이용한 분산 락(lock)을 지원하는 클라이언트

 

  Ex)  계좌 만들기

     - 프로젝트 만든다. => AccountApplication이 실행 창에 자동으로 뜬다.

 

 

 

7.3 lombok 

7.3-1 lombok (1)

  - lombok이란 자바의 보일러 플레이트 코드를 줄려주는 라이브러리이다.

 

 

  lombok의 기능들

  • @Setter, @Getter
  • @toString
  • @NoArgsConstructor / @AllArgsConstructor / @RequiredArgsConstructor: 객체의 생성자를 자동 생성
  • @Data: getter, setter, toString, Equals, hashCode 등
  • @Builder: 빌더 패턴을 자동 생성하여 제공한다.
  • @Slf4j: 해당 클래스의 logger를 자동 생성한다.
  • UtilityClass: static method만 제공하는 유틸리티 성격의 클래스들의  생성자를 private으로 만들어서 객체 생성할 수 없도록 한다.

 

  Ex) @Getter, @Setter 이용 방법

<hide/>
package com.example.account;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;

@Getter
@Setter
public class AccountDto {
    String accountNumber;
    String nickName;
    LocalDateTime registeredAt;
}
<hide/>
package com.example.account;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class AccountDtoTest {
    @Test
    void accountDto() {
        // given
        // when
        // then

        AccountDto accountDto = new AccountDto();
        accountDto.setAccountNumber("accountNumber");
        System.out.println(accountDto.getAccountNumber());
    }
}

  Note) 실행 결과

 

 

 

7.3-2 lombok (2)

 

  - 원래의 toSting()은 객체의 해시코드를 반환하도록 구현되어 있다.

  - 그런데 @ToString을 이용하면 해시코드가 아닌 객체의 데이터들에 대한 값을 보여준다. 

 

  Ex) AccountDto 클래스에 어노테이션 @ToString을 붙인 다음에 실행한다.

<hide/>
package com.example.account;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class AccountDtoTest {
    @Test
    void accountDto() {
        // given
        // when
        // then

        AccountDto accountDto = new AccountDto();
        accountDto.setAccountNumber("accountNumber");
        accountDto.setNickName("summer");

        System.out.println(accountDto.getAccountNumber());
        System.out.println(accountDto.toString());
    }
}

  Note) 실행 결과

 

 

 

  Ex)

<hide/>
package com.example.account;
import lombok.*;

import java.time.LocalDateTime;

@Getter
@Setter
@ToString
//@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
public class AccountDto {

    private String accountNumber;
    private String nickName;
    private LocalDateTime registeredAt;
}
<hide/>
package com.example.account;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
class AccountDtoTest {
    @Test
    void accountDto() {
        // given
        // when
        // then

        AccountDto accountDto = new AccountDto(
                "accountNumber",
                "summer",
                LocalDateTime.now()
        );
//        accountDto.setAccountNumber("accountNumber");
//        accountDto.setNickName("summer");
        System.out.println(accountDto.getAccountNumber());
        System.out.println(accountDto.toString());
    }
}

 

  Note) 실행 결과

 

 

  Note) 

    - @UtilityClass는 번거로운 코드들을 자동으로 만들어준다.

    - UtilityClass의 본연의 기능만 static만 사용 가능

 

 

7.4 HTTP Protocol

  - HTTP(HyperText Transfer Protocol): 단순 텍스트가 아닌 하이퍼 텍스트(다른 내용에 대한 링크를 갖는 문자열)를 전송하기 위한 프로토콜(약속된 정의)

 

 

7.5 H2 DB 설명과 활용 예제 개발

  

  Ex )

<hide/>
spring:
  datasource:
    url: jdbc:h2:mem:test
    username: sa
    password:
    driverClassName: org.h2.Driver
  h2:
    console:
      enabled: true
  jpa:
    defer-datasource-initialization: true
    database-platform: H2
    hibernate:
      ddl-auto: create-drop
    open-in-view: false
    properties:
      hibernate:
        format_sql: true
        show_sql: true

     - 새로운 클래스를 만든다.

    - application.yml 파일에 H2 리소스를 복사한다.

 

  Note) 실행 결과

    -h2가 실행가능하다고 뜬다.

 

 

 

  Ex)

    - http://localhost:8080/h2-console 에 접속하면 아래와 같은 화면이 나온다.

    - url에 jdbc:h2:mem:test 를 입력하고 connect누른다.

    - H2 DB가 로컬에 뜬 화면이다.

 

    - domain에 entity(일종의 설정 클래스)인 account클래스를 만든다.

      -> @Enumerated(EnumType.STRING) //  enum값에 실제 문자열을 그대로 DB에 저장한다.

<hide/>
package com.example.account.domain;
import lombok.*;
import javax.persistence.*;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
public class Account {

    @Id // account테이블의 pk로 id를 쓰겠다 선언한다.
    @GeneratedValue
    private Long id;

    private String accountNumber;

    @Enumerated(EnumType.STRING)    // enum 값에 실제 문자열을 그대로 DB에 저장한다.
    private AccountStatus accountStatus;
}

    - 열거형 만들기

<hide/>
package com.example.account.domain;
public enum AccountStatus {
    IN_USE, 
    UNREGISTERED;   // 계좌 해지
}

 

   Note) 실행 결과

    - 테이블이 만들어진 걸 확인 가능

 

    - 그리고 localhost에서도 테이블이 만들어진 걸 볼 수 있다.

 

 

 

  Ex) 예제 개발 (2)

<hide/>
package com.example.account.repository;
import com.example.account.domain.Account;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface AccountRepository extends JpaRepository<Account, Long> {

    
}

     - Account라는 테이블에 접속하기 위한 인터페이스 AccountRepository를 만든다.

     - 활용하게 될 entity => "Account"이고 Account의 primary key의 속성은 Long으로 지정한다.

 

    - AccountService 클래스를 만든다.

      -> final이나 notNull이 붙은 데이터의 생성자를 자동으로 추가해준다.

<hide/>
package com.example.account.service;
import com.example.account.domain.Account;
import com.example.account.domain.AccountStatus;
import com.example.account.repository.AccountRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;

@Service    // 서비스 타입 빈으로 스프링에 자동 등록해주기 위해서 붙인다.
@RequiredArgsConstructor
public class AccountService {
    
    private final AccountRepository accountRepository;  // 무조선 생성자에 들어가는 값

    @Transactional
    public  void createAccount(){   // 테이블에 데이터를 저장한다.
        Account account = Account.builder()
                .accountNumber("40000")
                .accountStatus(AccountStatus.IN_USE)
                .build();
        accountRepository.save(account);
    }
}

 

 

    - AccountController클래스를 추가한다.

       -> @RestController는 @Controller에 @ResponseBody가 추가된 것이다. Json 형태로 객체 데이터를 반환하는 역할을 한다.

<hide/>
package com.example.account.controller;
import com.example.account.service.AccountService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor
@RestController // 컨트롤러가 빈으로 등록
public class AccountController {
    private final AccountService accountService;

    @GetMapping("/create-account") // createAccunt API가 만들어졌다.
    public String createAccount(){
        accountService.createAccount();
        return  "success";
    }
}

  Note) 실행 결과

 

    - 아래의 sql문에서 ctrl + enter를 하면 태이블에 입력되는 걸 볼 수 있다.

 

 

    - http://localhost:8080/create-account를 여러 번 접속한 다음에 account테이블을 조회하면?

      -> 여러 개의 account가 만들어졌다.

      -> 중복 방지하는 속성 설정이 안되어있어서 여러 개가 들어갔다.

 

 

  Ex)

    - AccountService

<hide/>
package com.example.account.service;
import com.example.account.domain.Account;
import com.example.account.domain.AccountStatus;
import com.example.account.repository.AccountRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
@Service    // 서비스 타입 빈으로 스프링에 자동 등록해주기 위해서 붙인다.
@RequiredArgsConstructor
public class AccountService {
    private final AccountRepository accountRepository;  // 무조선 생성자에 들어가는 값
    @Transactional
    public  void createAccount(){   // 테이블에 데이터를 저장한다.
        //account 개체를 생성한다.
        Account account = Account.builder()
                .accountNumber("40000")
                .accountStatus(AccountStatus.IN_USE)
                .build();
        accountRepository.save(account);
    }
    @Transactional
    public Account getAccount(Long id) {   // 테이블에 데이터를 저장한다.
        return accountRepository.findById(id).get();
    }
}
<hide/>
package com.example.account.controller;
import com.example.account.domain.Account;
import com.example.account.service.AccountService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor
@RestController // 컨트롤러가 빈으로 등록
public class AccountController {
    private final AccountService accountService;

    @GetMapping("/create-account") // createAccunt API가 만들어졌다.
    public String createAccount(){
        accountService.createAccount();
        return  "success";
    }

    @GetMapping("/account/{id}") // createAccunt API가 만들어졌다.
    public Account getAccount(
            @PathVariable Long id){
            return accountService.getAccount(id);
    }
}

 

  Note) 실행 결과

    - http://localhost:8080/create-account 세 번 들어가서 생성한다. 그럼 아래과 같이 나온다.

    - 이런 식으로 DB에 있는 데이터를 가져올 수 있다.

 

 

 

7.6 트랜잭션

7.6-1 트랜잭션 개념 (ACID)

 

  트랜잭션 개념 - ACID

  • Atomic(원자성): All or Nothing 모든 작업이 실행되거나 혹은 모두 실행되지 않아야한다.
  •   ex) A => B 계좌로 잔액을 송금할 때, A계좌 잔액 줄이기 작업과 B계좌 잔액 늘리기 작업을 함께 성공/ 함께 실패해야한다.

 

  • Consistency(일관성): 모든 트랜잭션이 종료된 후에는 DB의 제약 조건을 모두 지키고 있는 상태가 되어야한다.
  •   ex) 잔액은 0원 이상이다. 이를 위반하는 트랜잭션은 중단

 

  • Isolation(격리성): 트랜잭션은 다른 트랜잭션과 독립적으로 동작해야한다. (성능과 안정성은 반비례 관계)
  •  현실적으로 완전한 격리성을 지키기 어렵다.  
  •   READ_UNCOMMITED > READ_COMMITTED > REPEATABLE_READ > SERIALIZATION
  • READ_UNCOMMITED: 거의 안 쓰임
  • READ_COMMITTED의 문제: 다른 트랜잭션을 종료한 경우에, 계속 읽을 수 있다. 그런데 내 트랜잭션이 긴 경우에, 처음 읽은 트랜잭션과 나중에 읽은 트랜잭션의 값이 달라질 수 있다.
  • REPEATABLE_READ: 시작하는 순간 스냅샷을 떠놓는다. 데이터가 일관적이다.
  • SERIALIZATION: 현업에서 많이 안 쓰인다. 직렬적으로 하나씩 처리
  • 오른쪽으로 갈수록 순서대로 성능 떨어지고 격리성(고립성) 증가
  • 격리성이 낮은 경우:Dirty Read(READ_UNCOMMITED의 문제), Phantom Read(READ_COMMITTED 에서 발생) 발생 가능하다.
  • MySQL inno DB의 기본 값이 REPEATABLE_READ 를 가장 많이 사용한다.

 

  • Durability(지속성): 트랜잭션이 commit을 하면 DB에 지속(저장)이 꼭 되어야 한다.

 

 

 

 

7.7 Embedded Redis 실행 (1)

7.7-1

 Redis(레디스, Remote Dictionary Server)란?

  • 키-값 구조의 비정형 데이터를 저자하고 관리하기 위한 오픈 소스 기반의 비관계형 데이터베이스 관리 시스템(DBMS)이다.

 

  Redis 사용 목적

  • spinlock(스핀락, 임계 구역에 진입이 불가능할 때, 진입 가능할 때까지 루프를 돌면서 재시도하는 방식으로 구현된 lock(mutex))를 활용한 동시성을 제어한다.
  • 동시성 제어를 AOP를 활용하여 실습하는데 활용되는 인프라

 

  localRedis 실행 과정

  • @Spring Boot를 가동하면서 Bean을 등록할 때마다 Redis를 실행하고 종료되면서 Bean을 삭제할 때마다 Redis를 종료하도록 설정한다.
  • 해당 Bean이 Redis Repository 보다 빨리 뜰 수 있도록 패키지 순서를 위쪽으로 해야한다.

 

 

7.7-2 Embedded Redis실행 (2)

 

  Ex) 

    - buile.gradle의 의존성을 추가한다.

<hide/>
plugins {
    id 'org.springframework.boot' version '2.6.10'
    id 'io.spring.dependency-management' version '1.0.12.RELEASE'
    id 'java'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    // redis client

    implementation 'org.redisson:redisson:3.17.3'

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

    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

 

    - 레디스 관련한 설정을 스프링 하위에 넣어 준다.

      -> application.yml 파일

<hide/>
spring:

  redis:
    host: 127.0.0.1
    port: 6379
  datasource:
    url: jdbc:h2:mem:test
    username: sa
    password:
    driverClassName: org.h2.Driver
  h2:
    console:
      enabled: true
  jpa:
    defer-datasource-initialization: true
    database-platform: H2
    hibernate:
      ddl-auto: create-drop
    open-in-view: false
    properties:
      hibernate:
        format_sql: true
        show_sql: true

 

    - LocalRedisConfig 클래스 - 빈 등록한다 (@Configuration 붙인다.)

      -> 레디스가 빈으로 등록된다.

<hide/>
package com.example.account.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import redis.embedded.RedisServer;

import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import javax.validation.Valid;

@Configuration
public class LocalRedisConfig {

    @Value("${spring.redis.port}")
    private int redisPort;  // 레디스를 띄워줄 포트를 넣어준다.

    private RedisServer redisServer;

    @PostConstruct

    public void startRedis(){
        redisServer = new RedisServer(redisPort);
        redisServer.start();
    }

    @PreDestroy
    public void stopRedis(){
        if(redisServer != null){    // 서버가 생성되면 스톱
            redisServer.stop();
        }
    }
}

 

  Note) 실행 결과

    - 단순히 별도의 레디스를 띄운 것이다. 레디스가 빈으로 등록되었다.

    - 레디스를 접근하기 위한 Client 빈으로 만들 수 있다.??

 

 

  Ex)   스핀락 실습

    - RedisRepositoryConfig클래스를 만든다.

<hide/>
package com.example.account.config;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedisRepositoryConfig {

    @Value("${spring.redis.host}")
    private String redisHost;

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

    @Bean
    public RedissonClient redissonClient(){ // 관례상 함수 이름이 빈으로 등록된다.
        Config config = new Config();
        config.useSingleServer().setAddress("redis://" + redisHost + ":" + redisPort);
        return Redisson.create(config); // 위 설정을 활용해서 딱 하나만 빈으로 등록한다.
    }
}

 

    - 테스트 하기 위해 RedisTestService클래스를 만들기

<hide/>
package com.example.account.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

@Slf4j
@Service
@RequiredArgsConstructor
public class RedisTestService {
    private final RedissonClient redissonClient;
    
    public String getLock(){
        RLock lock = redissonClient.getLock("sampleLock");// 락 이름이 샘플락, 자물쇠를 받는다.

        try{
            boolean isLock = lock.tryLock(1, 3, TimeUnit.SECONDS);
            // 최대 1초동안 기다리면서 자물쇠 찾는다.
            // 3초동안 갖고 있다가 풀어준다.
            if(!isLock){
                log.error("============Lock acquisition failed ==============");
                return  "Lock failed";
            }
        }catch (Exception e){
            log.error("Redis lock failed");
        }
        return  "Lock success";
    }
}

      -> RedisrLock에 있는 락을 가져온다. 

      -> 이 락으로 스핀락을 시도한다. 정해진 시간 내에 1초 내에 시도해보고 실패하면 실패하여 log.error를 출력한다.

      -> tryLock()을 이용한다.

<hide/>
package com.example.account.controller;
        import com.example.account.domain.Account;
        import com.example.account.service.AccountService;
        import com.example.account.service.RedisTestService;
        import lombok.RequiredArgsConstructor;
        import org.springframework.web.bind.annotation.GetMapping;
        import org.springframework.web.bind.annotation.PathVariable;
        import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController // 컨트롤러가 빈으로 등록
public class AccountController {
    private final RedisTestService redisTestService;
    private final AccountService accountService;

    @GetMapping("/get-lock")    // 락을 획득해온다.
    public String getLock(){
        return redisTestService.getLock();
    }

    @GetMapping("/create-account") // createAccount API가 만들어졌다.
    public String createAccount(){
        accountService.createAccount();
        return  "success";
    }

    @GetMapping("/account/{id}")
    public Account getAccount(
            @PathVariable Long id){
        return accountService.getAccount(id);
    }
}

  Note) 실행 결과 - http://localhost:8080/get-lock입력

    - 금방 새로고침을 하면 failed가 뜨고 3초 뒤에 다시 누르면 success가 뜬다.

    - 로컬에 임베디드 레디스를 등록하고 자동으로 어플리케이션이 뜰 때, 빈을 등록하면서 레디스를 ??시켜주고  레디스가 (어플이 꺼질 때)빈을 파괴할 때, 바로 꺼지도록 한다.

 

7.8  테스트(JUnit, Mockito, Spring Boot)

 

7.8-1 테스트의 중요성

  - 옛날에는 sql 코드는 자동화된 테스트가 어려운 코드가 많았다.

  - 테스트 하면서 자연스럽게 내 코드를 셀프 리뷰하게 된다.

  - 장기적으로 볼 때 더 빠르고 안정적인 개발이 가능한다.

  - 유닛테스트의 경우, 적잘한 Mocking(모의 객체)으로 격리성을 확보한다. 

 

 

7.8-2 JUnit, Mockito

 

  JUnit, Mockito

  • JUnit은 xUnit이라는 유닛테스트 프레임워크의 일환으로 Java용으로 개발된 프레임워크이다.
  • JUnit은 단위테스트를 실행하여 결과를 검증해서 전체 결과를 리포트해주는 프레임워크이다.
  • 사용자가 직접 동작시킬 수 있으면 Gradle, Maven 등을 통해 빌드하면서 테스트 가능하다.
  • spring-boot-starter-test에 기본적으로 JUnit5가 포함된다.

 

  Ex)

    - AccountServiceTest 클래스를 만든다.

<hide/>
package com.example.account.service;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class AccountServiceTest {

    @Test
    void tstSomething(){
        String something = "Hello " + "World!";
        assertEquals("Hello World", something); // 검증
    }
}

  Note) 실행 결과 - 느낌표 때문에 틀린 것을 보여준다.

 

  Ex)

<hide/>
package com.example.account.service;
import com.example.account.domain.Account;
import com.example.account.domain.AccountStatus;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
class AccountServiceTest {

    @Autowired
    private AccountService accountService;

    @Test
    void testGetAccount(){
        accountService.createAccount();
        Account account = accountService.getAccount(1L);
//        assertEquals("40000", account.getAccountNumber()); // 검증
//        assertEquals(AccountStatus.IN_USE, account.getAccountStatus()); // 검증

        assertEquals("40000", account.getAccountNumber());
        assertEquals(AccountStatus.IN_USE, account.getAccountStatus());

    }
}

    - getAccount()를 테스트해보자

    - @SpringBootTest: 스프링 부트를 테스트한다. 컨텍스트, 환경, 맥락 등 을 테스트 용으로 실제와 동일한 상태에서 모든 빈을 생성해서 원하는 대로 테스트 한다. (빈이 생성됐으니 주입 @Autowired 가능하다.)

    - @Autowired: 필요한 의존 객체의 타입에 해당하는 빈을 찾아서 주입한다. 생성자 / Setter /  필드 ... 에서 사용 가능

 

  Note) 실행 결과 

 

=====에러 났을 때 화면 =====

  - assertEquals의 매개변수로 값이 다르게 들어가는 경우에는 테스트 결과창에 느낌표가 아니라 x가 뜨는게 정상인데 느낌표 뜨는 걸 보니까 뭔가 잘못됐다.

 

 

 

    ======두번쨰 오류=========

 

 

 Mocking

  • mokito: Mock을 만들어주는 라이브러리
  • 테스트하고자 하는 클래스가 의존하는 클래스를 모두 만드려다보니 테스트 만들기가 어렵다.
  • 모든 클래스가 동작하면 어떤 부분이 문제인지 알기 어렵다.
  • 가짜(mock)를 만들어서 내가 원하는 방식으로 동작하게 하기 위해 Mokito라이브러리를 활용한다.

 

  Ex)

<hide/>
package com.example.account.service;
import com.example.account.domain.Account;
import com.example.account.domain.AccountStatus;
import com.example.account.repository.AccountRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.BDDMockito.given;
import static org.mockito.ArgumentMatchers.anyLong;
@ExtendWith(MockitoExtension.class)
class AccountServiceTest {

    @Mock
    private AccountRepository accountRepository;
    // 가짜로 만들어서 넣어준다.

    @InjectMocks
    private AccountService accountService;

    @Test
    @DisplayName("계좌 조회 성공")
    void testXXX(){
        //given
       given(accountRepository.findById(anyLong()))
               .willReturn(Optional.of(Account.builder()
                   .accountStatus(AccountStatus.UNREGISTERED)
                   .accountNumber("65789")
                   .build()));

       // when
       Account account =  accountService.getAccount(4555L);

       // then
        assertEquals("65789", account.getAccountNumber());
        assertEquals(AccountStatus.UNREGISTERED, account.getAccountStatus());
    }

    @Test
    @DisplayName("Test 이름 변경")
    void testGetAccount(){
        Account account = accountService.getAccount(1l);
        assertEquals("40000", account.getAccountNumber());
        assertEquals(AccountStatus.IN_USE, account.getAccountStatus());

    }
//
//    @Test
//    void testGetAccount2(){
//        Account account = accountService.getAccount(2L);
//        assertEquals("40000", account.getAccountNumber());
//        assertEquals(AccountStatus.IN_USE, account.getAccountStatus());
//
//    }
}
<hide/>
package com.example.account.service;
import com.example.account.domain.Account;
import com.example.account.domain.AccountStatus;
import com.example.account.repository.AccountRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;

@Service    // 서비스 타입 빈으로 스프링에 자동 등록해주기 위해서 붙인다.
@RequiredArgsConstructor
public class AccountService {
    private final AccountRepository accountRepository;
    // 무조선 생성자에 들어가는 값
    @Transactional
    public  void createAccount(){   // 테이블에 데이터를 저장한다.
        //account 개체를 생성한다.
        Account account = Account.builder()
                .accountNumber("40000")
                .accountStatus(AccountStatus.IN_USE)
                .build();
        accountRepository.save(account);
    }
    @Transactional
    public Account getAccount(Long id) {   // 테이블에 데이터를 저장한다.
        return accountRepository.findById(id).get();
    }
}

    -  JUnit을 그대로 쓰면 모키토 기능을 쓸 수 없기 때문에 모키토 확장 팩을 달아준다.

    - 의존된 부분을 가짜로 만들어서 넣어줘야한다.

    - @InjectMocks를 붙이면 위에서 가짜로 만든 accountRepository를  accountServic에 주입한다.

 

   Note) 실행 결과 - 에러 수정

      - NoSuchMethodException는 런타임 시점에 존재하지 않는 메거르르 호출하는 경우에 발생한다. 컴파일 때는 매서드가 존재 했지만 런타임시점에는 찾을 수 없었다는 것을 의미한다.

 

 

 

  Cointroller 테스트 방법

  • @SpringBootTest(모든 bean이 다 뜬다.) + @AutoConfigureMockMvc : 저체 bean을 모두 생성한 다음 mockMvc를 통해 http요청과 검증을 진행한다.
  • @WebMvcTest: 내가 필요로 하는 MVC  관련 Bean만 생성한다. Service 등 Controller에서 의존하는 하위 레이어 기능은 @MockBean을 통해 모킹에서 원하는 동작을 하도록 한다.(Mokito와 유사한 방식이다.

 

  Ex) AccountControllerTest 클래스

    - WebController를 사용해서 특정 컨트롤러만 격리시켜서 컨트롤러 테스트를 unit 단위로 테스트 할 수 있다.

<hide/>
package com.example.account.controller;
import com.example.account.domain.Account;
import com.example.account.domain.AccountStatus;
import com.example.account.service.AccountService;
import com.example.account.service.RedisTestService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// mock mvc를 만들어서 제공해준다.
@WebMvcTest (AccountController.class)
class AccountControllerTest {

    @MockBean   // 가짜로 빈 등록
    private AccountService accountService;

    @MockBean
    private RedisTestService redisTestService;

    @Autowired
    private MockMvc mockMvc;

    @Test
    void successGetAccount() throws Exception {
        // given
        given(accountService.getAccount(anyLong()))
                .willReturn(Account.builder()
                        .accountNumber("3456")
                        .accountStatus(AccountStatus.IN_USE)
                        .build());  // 모킹된 상태

        // when
        // then
        mockMvc.perform(get("/account/876"))    // 테스트 하려는 url
                .andDo(print())
                .andExpect(jsonPath("$.accountNumber").value("3456")) // 바디의 첫 번째 구조에 있는 값이
                .andExpect(jsonPath("$.accountStatus").value("IN_USE"))
                .andExpect(status().isOk());
    }
}

 

  Note) 실행 화면 (에러 수정 후)

    - build.gradle 파일을 수정했다.

 

 

  Service 테스트의 다양한 방법

  • verify: 의존하고 있는 Mock이 해당되는 동작을 수행했는지 확인하는 검증, 특정 동작을 몇 번 실행할 지 검증한다.
  • ArgumentCaptor: 의존하고 있는 Mock에 전달된 데이터가 내가 의도하는 데이터가 맞는지 검증한다.
  • assertions: 다양한 단언(assertion) 방법들 - assertEquals / assertNotEquals / assertNull / assertNotNull / assertTrue / assertFalse / assertAll(항상 전체를 검사 한다.)
  • assertThrows: 예외를 던지는 로직을 테스트하는 방법

 

  Ex)

<hide/>
package com.example.account.service;
import com.example.account.domain.Account;
import com.example.account.domain.AccountStatus;
import com.example.account.repository.AccountRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class AccountServiceTest {

    @Mock
    private AccountRepository accountRepository;
    // 가짜로 만들어서 넣어준다.

    @InjectMocks
    private AccountService accountService;

    @Test
    @DisplayName("계좌 조회 성공")
    void testXXX(){
        //given
       given(accountRepository.findById(anyLong()))
               .willReturn(Optional.of(Account.builder()
                   .accountStatus(AccountStatus.UNREGISTERED)
                   .accountNumber("65789").build()));
       ArgumentCaptor<Long> captor = ArgumentCaptor.forClass(Long.class);

       // when
        Account account =  accountService.getAccount(4555L);

       // then
        verify(accountRepository, times(1)).findById(captor.capture());
        verify(accountRepository, times(0)).save(any());
        assertEquals(4555L, captor.getValue());
        assertEquals("65789", account.getAccountNumber());
        assertEquals(AccountStatus.UNREGISTERED, account.getAccountStatus());
    }

    @Test
    @DisplayName("Test 이름 변경")
    void testGetAccount(){
        Account account = accountService.getAccount(1l);
        assertEquals("40000", account.getAccountNumber());
        assertEquals(AccountStatus.IN_USE, account.getAccountStatus());

    }

    @Test
    void testGetAccount2(){
        Account account = accountService.getAccount(2L);
        assertEquals("40000", account.getAccountNumber());
        assertEquals(AccountStatus.IN_USE, account.getAccountStatus());

    }
}

    - 계좌를 조회할 때, findById()는 한 번만 호출되고 save()는 한 번도 호출되지 않는다.

 

  Note) 실행 결과

    - buled.gradle 파일을 강사님 파일 참고해서 수정하니까 해결됐다. java.lang.NoSuchMethodError

 

  Note) 실행 결과

    - assertEquals()에 다른 매개변수를 넣으면?

 

  

  Ex) 음수가 들어오면?

     - 테스트 클래스에서 다음 코드를 실행한다.

<hide/>
@Test
@DisplayName("계좌 조회 실패 - 음수로 조회")
void testFailedToSearchAccount(){
    //given - 모킹으로 못 가니까 의미 없음
    // when
    Account account =  accountService.getAccount(-10L);
    // then
    assertEquals("65789", account.getAccountNumber());
    assertEquals(AccountStatus.UNREGISTERED, account.getAccountStatus());
}

  Note) 실행 결과

 

 

 

  Ex)  음수에 대해 다음과 같이 예외처리를 해보자

<hide/>  
@Test
    @DisplayName("계좌 조회 실패 - 음수로 조회")
    void testFailedToSearchAccount(){
        //given - 모킹으로 못 가니까 의미 없음

        // when
        RuntimeException exception = assertThrows(RuntimeException.class,
                () ->  accountService.getAccount(-10L));    // 예외가 생기는 동작작
       // then
        assertEquals("Minus", exception.getMessage());
    }

  Note) 실행 결과