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) 실행 결과
'Spring Projcect > 계좌 관리 시스템 프로젝트' 카테고리의 다른 글
Chapter 09. Account(계좌) 시스템 업그레이드 (0) | 2022.08.05 |
---|---|
Chapter 08. Account(계좌) 시스템 개발 (0) | 2022.08.02 |
Chapter 06. 스프링 MVC(Model-View-Controller) (0) | 2022.07.27 |
Chapter 03. 자바에서 스프링으로 (0) | 2022.07.19 |
Chapter 02. OOP(Object Oriented Pragramming)와 스프링 프레임워크 (0) | 2022.07.19 |