Member: 회원 ID, 이름 속성을 포함.요구사항을 고려하여, 데이터베이스 저장소(RDB, NoSQL 등)가 아직 선택되지 않은 상황에서는 초기 개발 단계를 위해 가벼운 메모리 기반 데이터 저장소를 구현체로 사용한다. 추후 변경 가능성을 대비하여 MemoryRepository 인터페이스를 설계하고, 이를 구현한 MemoryMemberRepository를 통해 메모리 기반 데이터 저장소를 구현한다.
package hello.hello_spring.domain;
// 도메인 Member 클래스
public class Member {
private Long Id;
private String name;
public Long getId() {
return Id;
}
public void setId(Long id) {
Id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
// 회원 리포지토리 인터페이스
package hello.hello_spring.repository;
import hello.hello_spring.domain.Member;
import java.util.List;
import java.util.Optional;
public interface MemberRepository {
Member save(Member member);
Optional<Member> findById(Long id);
Optional<Member> findByName(String name);
List<Member> findAll();
}
// 회원 리포지토리 메모리 구현체
// 동시성 문제 고려되어 있지 않음.
package hello.hello_spring.repository;
import hello.hello_spring.domain.Member;
import java.util.*;
public class MemoryMemberRepository implements MemberRepository{
private static Map<Long, Member> store = new HashMap<>();
private static long sequence = 0L;
@Override
public Member save(Member member) {
member.setId(sequence++);
store.put(member.getId(), member);
return member;
}
@Override
public Optional<Member> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
@Override
public Optional<Member> findByName(String name) {
return store.values().stream()
.filter(member -> member.getName().equals(name))
// member를 파라미터로 한다.
// stream내에는 많은 member들이 존재한다.
// 각각의 member.getName().equals(name)으로부터 boolean을 얻는다.
// filter는 boolean를 기준으로 true인 member만 재 구성한다.
.findAny();
// findAny로 재 구성된 stream중 하나를 return한다.
// stream에는 member가 없을 수도, 1개가 존재할 수도 있다.
// findAny는 값이 없어도 빈 Optinal을 뱉기에 ofNullable사용이 필요없다.
}
@Override
public List<Member> findAll() {
return new ArrayList<>(store.values());
}
public void clearStore() {
store.clear();
}
}
개발한 기능은 main 메서드나 컨트롤러를 통해 실행할 수 있지만, 이러한 방법은 준비와 실행에 시간이 오래 걸리고 반복 실행이 어렵다. 또한, 여러 테스트를 한 번에 실행하기도 어려운 단점이 있다. 이러한 문제를 해결하기 위해 자바는 JUnit이라는 프레임워크를 제공하며, 이를 통해 간편하고 효율적으로 테스트를 실행할 수 있다.
// repository test case
package hello.hello_spring.repository;
import hello.hello_spring.domain.Member;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;
public class MemoryMemberRepositoryTest {
MemoryMemberRepository repository = new MemoryMemberRepository();
@AfterEach
public void afterEach() {
repository.clearStore();
}
@Test
public void save() {
Member member = new Member();
member.setName("spring");
repository.save(member);
Member result = repository.findById(member.getId()).get();// Optional에서 값 뽑기
assertThat(member).isEqualTo(result);
}
@Test
public void findByName() {
Member member1 = new Member();
member1.setName("spring1");
repository.save(member1);
Member member2 = new Member();
member2.setName("spring2");
repository.save(member2);
Member result = repository.findByName("spring1").get();
assertThat(member1).isEqualTo(result);
}
@Test
public void findAll() {
Member member1 = new Member();
member1.setName("spring1");
repository.save(member1);
Member member2 = new Member();
member2.setName("spring2");
repository.save(member2);
List<Member> result = repository.findAll();
assertThat(result.size()).isEqualTo(2);
}
}
테스트 케이스를 처음 작성하는 경우, //given //when //then으로 나누어 논리적으로 작성할 수 있다. 이는 테스트를 명확하게 구조화할 수 있도록 도와준다.
Arrange-Act-Assert 패턴
given (Arrange)
- 설정 단계로, 테스트를 준비하는 부분이다.
- 이 단계에서는 테스트에 필요한 모든 초기 상태를 설정한다. 예를 들어, 테스트할 객체를 생성하고 필요한 데이터나 환경을 준비한다.
when (Act)
- 행동 단계로, 실제 테스트할 동작을 수행하는 부분이다.
- 이 단계에서는 테스트하려는 메서드나 기능을 호출한다.
then (Assert)
- 검증 단계로, 테스트 결과를 확인하는 부분이다.
- 이 단계에서는 기대하는 결과와 실제 결과를 비교하여 테스트가 성공했는지 확인한다.
이러한 구조를 사용하면 테스트 코드가 체계적이고 가독성이 높아져 유지보수가 용이해진다.
@Test
public void save() {
Member member = new Member();
member.setName("spring");
repository.save(member);
Member result = repository.findById(member.getId()).get();// Optional에서 값 뽑기
assertThat(member).isEqualTo(result);
}
save 메서드의 내부 코드를 기준으로, 테스트는 Arrange-Act-Assert 패턴에 따라 다음과 같이 구분된다:
assertThat(member).isEqualTo(result);는 검증 기준을 member로 설정하고, result와 동등한지 확인하는 역할을 한다.
@AfterEach는 JUnit 5에서 제공하는 어노테이션으로, 각 테스트 메서드 실행이 종료된 후에 실행할 메서드를 지정하는 데 사용된다.
현재 경우에서는 테스트 과정에서 생성된 MemoryMemberRepository를 초기화(clear)하기 위해 활용되었다.
초기화를 수행하지 않으면, findByName과 findAll이 내부적으로 동일한 name 값을 사용하게 되어 테스트 간 상태가 공유된다.
이로 인해, 테스트가 독립적으로 실행되지 못하고, 성공해야 할 검증임에도 불구하고 검증이 실패하는 문제가 발생할 수 있다.
@AfterEach는 이러한 문제를 방지하기 위해, 각 테스트 후 상태를 초기화하여 테스트의 신뢰성과 독립성을 보장한다.
//java
package hello.hello_spring.service;
import hello.hello_spring.domain.Member;
import hello.hello_spring.repository.MemoryMemberRepository;
import java.util.List;
import java.util.Optional;
public class MemberService {
private final MemoryMemberRepository memberRepository;
public MemberService(MemoryMemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
public Long join(Member member) {
validateDuplicateMember(member);
memberRepository.save(member);
return member.getId();
}
private void validateDuplicateMember(Member member) {
memberRepository.findByName(member.getName())
.ifPresent(m -> {
throw new IllegalStateException("이미 존재하는 회원입니다.");
});
}
public List<Member> findMemebers() {
return memberRepository.findAll();
}
public Optional<Member> findOne(Long memberId) {
return memberRepository.findById(memberId);
}
}
회원 서비스는 생성자를 통해 memberRepository를 전달받는다.
이는 데이터 저장소를 유연하게 변경할 수 있도록 설계된 것으로, 만약 DB가 결정되었다면 다른 구현체로 만들어진 memberRepository가 사용될 것이다. 현재는 MemoryMemberRepository를 사용한다.
join
memberRepository에 추가된다.findMembers
findOne
Optional<Member>로 지정된다.Optional은 반환값으로 Member 객체뿐만 아니라, null 발생 가능성을 함께 처리할 수 있도록 설계되었다.
이를 통해, 리포지토리에서 회원을 찾지 못했을 때 발생할 수 있는 NullPointerException을 방지하고, 보다 안전하게 값을 처리할 수 있다.
예를 들어, 호출자는 Optional 객체를 통해 값이 존재하는지 확인한 후, 적절한 후속 작업을 수행할 수 있다.
package hello.hello_spring.service;
import hello.hello_spring.domain.Member;
import hello.hello_spring.repository.MemoryMemberRepository;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class MemberServiceTest {
MemberService memberService;
MemoryMemberRepository memberRepository;
@BeforeEach
public void beforeEach() {
memberRepository = new MemoryMemberRepository();
memberService = new MemberService(memberRepository);
}
@AfterEach
public void afterEach() {
memberRepository.clearStore();
}
@Test
void 회원가입() {
//given
Member member = new Member();
member.setName("hello");
//when
Long saveId = memberService.join(member);
//then
Member findMemeber = memberService.findOne(saveId).get();
Assertions.assertThat(member.getName()).isEqualTo(findMemeber.getName());
}
@Test
public void 중복_회원_예외() {
//given
Member member1 = new Member();
member1.setName("spring");
Member member2 = new Member();
member2.setName("spring");
//when
memberService.join(member1);
// 람다 처럼 했을 때 해당 예외가 터져야해!
IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));
Assertions.assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
//then
}
MemberService를 생성하기 위해서는 생성자로 memberRepository를 받아야 한다
@BeforeEach는 JUnit 5에서 제공하는 어노테이션으로, 각 테스트 메서드가 실행되기 전에 실행할 메서드를 지정한다.
이를 활용해, 테스트 시작 시마다 리포지토리를 초기화하고, 새로운 MemberService 인스턴스를 생성하도록 설정할 수 있다.
이러한 초기화 과정은 테스트의 독립성을 보장하고, 테스트 간 상태 공유로 인한 오류를 방지한다.
TestCase는 실제로 빌드될 때 빠지므로 가독성을 위해 메서드 이름을 한글로 해도 무방하다.
assertThrows : 테스트 코드에서 예외가 발생하는 것을 확인하기 위해 사용되는 메서드이다. 즉 예외를 잡기위한 코드이다.
첫번째 인자로 발생할 예외 클래스를 지정하고, 두번 째로 예외 발생시나리오를 던져준다. 의도한 예외 발생이 성공한다면 e에 예외가 담길 것이다. 다른 타입의 예외가 들어오거나 예외가 발생하지 않을 시 테스트 실패 예외가 터지고 테스트는 실패한다.
Assertions.assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
public Long join(Member member) {
validateDuplicateMember(member);
memberRepository.save(member);
return member.getId();
}
private void validateDuplicateMember(Member member) {
memberRepository.findByName(member.getName())
.ifPresent(m -> {
throw new IllegalStateException("이미 존재하는 회원입니다.");
});
}
우리가 join에서 getMesssage를 위와 같이 설정했으므로 validateDuplicateMember에 실패한다면(findByName으로 member객체를 찾을 경우 람다에 전달된 예외 발생) IllegalStateException예외와 "이미 존재하는 회원입니다."라는 예외 메세지가 던져지므로 isEqualTo에 성공한다.
즉 중복회원예외 시나리오를 그대로 구현한다. 이 시나리오가 가능한 것인지 검증을 성공한 것이다.(테스트 성공시)