본문 바로가기
TIL

2023-10-31-TIL

by raphael3 2023. 11. 1.
반응형

스프링 통합 테스트

이전의 테스트들(참고1, 참고2)은 스프링과는 연관없는 순수한 자바 코드로 테스트하는 것들이었고, 그렇게 해도 괜찮았지만, DB connection 정보 등을 스프링이 들고 있는 직전 노트에 따라 스프링과 엮어서 테스트할 필요가 있다.


src/test/java/hello/hellospring/service/MemberServiceIntegrationTest.java

: test/service 디렉토리 이하의 MemberServiceTest.java를 복붙

@SpringBootTest

package hello.hellospring.service;

...

@SpringBootTest
@Transactional
class MemberServiceIntegrationTest {
    ...
}

스프링과 엮어서 테스트하기 위해서는 두 개의 어노테이션이 필요: @SpringBootTest, @Transactional

스프링과 엮어야 하는 이유는, 스프링이 들고 있는 스프링 빈을 테스트 시에 이용해야하기 때문이다. 즉, @SpringBootTest가 테스트시에 스프링 컨테이너를 띄워주는 역할을 한다.

package hello.hellospring.service;

...

@SpringBootTest
@Transactional
class MemberServiceIntegrationTest {
    MemoryMemberRepository memberRepository = new MemoryMemberRepository();
    MemberService memberService;

    @BeforeEach
    public void beforeEach() {
        memberService = new MemberService(memberRepository);
    }

		...
}

스프링이 Service와 Repository를 알고 있기 때문에(즉 Service와 Repository가 스프링 빈으로 이미 등록되어 있기 때문에), 필요한 객체를 new로 직접 만들 필요 없이 스프링에게 요구하면 된다. ⇒ 이는 제어의 역전이기 때문(IoC; Inversion of Control)이다.

스프링에게 필요한 것을 요구하는 도구는 @Autowired.

package hello.hellospring.service;

...

@SpringBootTest
@Transactional
class MemberServiceIntegrationTest {
    // @Autowired MemoryMemberRepository memberRepository = new MemoryMemberRepository();
	@Autowired MemberRepository memberRepository = new MemoryMemberRepository();
    @Autowired MemberService memberService;

		...
}

테스트 코드를 다른 곳에서 가져다 쓰지 않기 때문에 편한 방식을 쓰면 된다. 그래서 위 코드에서는 필드 주입 방식을 쓴다.

MemoryMemberRepository 대신에 MemberRepository를 써야 하는 이유는 @Configuration+@Bean으로 등록된 스프링 빈은 인터페이스인 MemberRepository이기 때문이다;

package hello.hellospring.service;

...

@SpringBootTest
@Transactional
class MemberServiceIntegrationTest {
    @Autowired MemberRepository memberRepository = new MemoryMemberRepository();
    @Autowired MemberService memberService;

//    @AfterEach
//    public void afterEach() {
//        memberRepository.clearStore();
//    }

    ...
}

afterEach 콜백 메서드도 더이상 필요하지 않은데(즉, Repository를 명시적으로 clear할 필요가 없는데), 그 이유는 @Transactional 어노테이션 덕분이다.

@Transactional

package hello.hellospring.service;

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

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

@SpringBootTest
@Transactional
class MemberServiceIntegrationTest {
    @Autowired MemberRepository memberRepository = new MemoryMemberRepository();
    @Autowired MemberService memberService;

    @Test
    void 회원가입_정상작동여부검증() {
        Member member = new Member();
        member.setName("Spring");
				// member.setName("KwonTedd");

        Long memberId = memberService.join(member);
        System.out.println("member getName" + member.getName());

        Member foundMember = memberService.findOne(memberId).get();
        assertThat(foundMember.getName()).isEqualTo(member.getName());
    }

    @Test
    void 회원가입_중복회원검증() {
        Member memberA = new Member();
        memberA.setName("KwonTedd");

        Member memberB = new Member();
        memberB.setName("KwonTedd");

        memberService.join(memberA);
        System.out.println("memberA getName" + memberA.getName());
        IllegalStateException e = assertThrows(IllegalStateException.class, () -> {
            memberService.join(memberB);
            System.out.println("memberB getName" + memberB.getName());
        });

        assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
    }
}

이 상태로 회원가입_정상작동여부검증에 대한 테스트를 Run하면 에러가 뜨는데, 이는 이미 DB에 동일한 값을 가진 name이 존재하기 때문이다. 만약 테스트하려는 DB에 존재하지 않는 다른 name으로 setName을 하면 에러가 뜨지 않을 것이다.

보통은 테스트를 위한 테스트 전용 DB가 따로 있어서 해당 DB에서 테스트를 수행하게 된다.

현재 DB에는 존재하지 않는 name인 “KwonTedd”로 setName을 수행한 후, 이 때의 H2 DB 상황을 보면 다음과 같이 값이 실제로 들어가지는 않은 것으로 보인다.

하지만 @Transactional을 없애고 다시 Run하면, 테스트할 때 사용된 데이터가 실제 DB에도 들어간 것을 볼 수 있다.

이를 처리하려면 다시 @BeforeEach, @AfterEach 등의 어노테이션으로 처리해줘야 한다.

 

DB에 데이터가 반영되려면 commit이 이루어져야 한다. 따라서 commit을 실제로 수행하기 전까지는 DB에는 어떤 사항도 반영되지 않는다. 만약 트랜잭션 도중에 commit하지 않고 rollback하면? 트랜잭션 자체가 취소된다.

@Transactional은 테스트 수행 시 트랜잭션을 실행하여 실제로 DB에 데이터를 insert까지 한 후, 테스트가 끝나면 rollback해주는 역할을 한다. 따라서, 테스트 코드 작성 시에는 DB에 대한 rollback을 위해서 @Transactional을 사용해야 한다.

이를 통해 테스트끼리는 의존성이 없어야 한다는 원칙도 달성된다.


즉, 지금까지 알아본 테스트를 크게 두 가지 부류로 나눌 수 있다.

  1. 단위 테스트: 이전의 테스트들(참고1, 참고2)
  2. 스프링 통합 테스트: 이 노트에서 다룬 테스트

스프링 통합 테스트만 쓰면 될까? 그건 아니다. 단위 테스트가 스프링 통합 테스트보다 실행시간이 더 빠르다. 또한, 스프링 컨테이너 없이도 테스트는 돌아갈 수 있어야 한다. 김영한님에 의하면 스프링 통합 테스트보다는 단위 테스트가 더 좋은 테스트다.


스프링 JDBC template

JDBC template과 함께 MyBatis라는 라이브러리도 있는데 이들은 실무에서도 많이 사용된다. 이들 라이브러리는 JDBC의 반복코드 부분을 제거해준다. 하지만 SQL은 직접 작성해야만 한다.

참고로, JdbcTemplate 라이브러리는 내부적으로 디자인 패턴 중 템플릿 메서드 패턴이 주로 쓰이기 때문에 붙여진 이름이다.

설정(build.gradle)은 기존의 JDBC 작업을 할 때와 동일하다; 참고 1


package hello.hellospring.repository;

import hello.hellospring.domain.Member;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

import javax.sql.DataSource;
import java.util.List;
import java.util.Optional;

@Repository
public class JdbcTemplateMemberRepository implements MemberRepository{
    private final JdbcTemplate jdbcTemplate;

    @Autowired
    public JdbcTemplateMemberRepository(DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

    ...
}

JDBC template을 사용하기 위해서는 JdbcTemplate과 DataSource가 필요하다.

JdbcTemplate은 주입받을 수 없기 때문에, 생성자에서 new로 생성해주어야 하며, 생성자 주입으로 DataSource를 주입받아 JdbcTemplate을 생성할 때 넣어준다.

참고로, 생성자가 단 한 개면 @Autowired를 생략할 수 있다. 다른 사람의 코드를 볼 때 생성자가 한 개 있고, @Autowired가 생략되어 있더라도 당황하지 말자.


jdbcTemplate.query(SQL, RowMapper, SQL에 넘길 값)

package hello.hellospring.repository;

import hello.hellospring.domain.Member;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

import javax.sql.DataSource;
import java.util.List;
import java.util.Optional;

@Repository
public class JdbcTemplateMemberRepository implements MemberRepository{
    private final JdbcTemplate jdbcTemplate;

    @Autowired
    public JdbcTemplateMemberRepository(DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

    @Override
    public Optional<Member> findById(Long id) {
		jdbcTemplate.query("select * from member where id = ?", ???, id);
    }

    ...
}

JdbcTemplate의 query 메서드는 세 개의 파라미터를 받는다; SQL 쿼리, RowMapper, 넘길 값

JdbcTemplate을 이용한 SQL 쿼리의 결과를 RowMapper를 이용해서 Member 객체로 Mapping해줘야 한다. 또한, SQL 문의 ?에 넘길 값을 알려줘야 한다.

RowMapper

...

private RowMapper<Member> memberRowMapper() {
	...        
}

...
...

private RowMapper<Member> memberRowMapper() {
    return new RowMapper<Member>() {
        @Override
        public Member mapRow(ResultSet rs, int rowNum) throws SQLException {
            return null;
        }
    }
}

...

RowMapper는 인터페이스이기 때문에 구현해준다.

...

private RowMapper<Member> memberRowMapper() {
    return new RowMapper<Member>() {
        @Override
        public Member mapRow(ResultSet rs, int rowNum) throws SQLException {
            Member member = new Member();
            member.setId(rs.getLong("id"));
            member.setName(rs.getString("name"));
            return member;
        }
    };
}

...

mapRow() 메서드의 return이 Member다. 또한, findById 메서드에 쓰이는 RowMapper이기 때문에, Member를 생성한다. SQL문을 실행하면 그 결과가 ResultSet에 담기는데, id와 name을 설정하기 위해 getId, getName으로 id와 name을 설정했던 이전의 노트와 같은 원리로 member의 id와 name을 설정한다. 또한, ResultSet으로부터 값을 뽑아내기 위한 getLong과 getString을 사용한다.

...

private RowMapper<Member> memberRowMapper() {
    return (rs, rowNum) -> {
        Member member = new Member();
        member.setId(rs.getLong("id"));
        member.setName(rs.getString("name"));
        return member;
    };
}

...

RowMapper 객체를 람다 함수로 바꿀 수 있다. Opt + Enter.

RowMapper 작성은 끝났다.

package hello.hellospring.repository;

import hello.hellospring.domain.Member;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;

import javax.sql.DataSource;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import java.util.Optional;

@Repository
public class JdbcTemplateMemberRepository implements MemberRepository{
    private final JdbcTemplate jdbcTemplate;

    @Autowired
    public JdbcTemplateMemberRepository(DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

    @Override
    public Optional<Member> findById(Long id) {
        List<Member> result = jdbcTemplate.query("select * from member where id = ?", memberRowMapper(), id);
        return result.stream().findAny();
    }

    ...

    private RowMapper<Member> memberRowMapper() {
        return (rs, rowNum) -> {
            Member member = new Member();
            member.setId(rs.getLong("id"));
            member.setName(rs.getString("name"));
            return member;
        };
    }
}

memberRowMapper()를 JdbcTemplate의 쿼리 메서드에 넘기면, 결과가 Member타입을 담는 List 자료구조로 나온다. stream을 이용해서 어떤 값이든 있으면 찾아서 반환하되, 없을 수도 있기 때문에 Optional로 반환할 수 있게 하기 위해, stream().findAny()를 사용한다. stream().findAny()를 사용하면 결과물이 자동적으로 Optional로 감싸져서 나온다는 점을 참고한다(링크).

기존의 순수 JDBC와 비교했을 때 코드가 매우 단순해진 것을 볼 수 있다.

findByName 메서드도 유사한 코드를 가져가며, findAll의 경우 반환 타입이 List<Member>이기 때문에 더 간단하다.

package hello.hellospring.repository;

import hello.hellospring.domain.Member;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.simple.SimpleJdbcInsert;
import org.springframework.stereotype.Repository;

import javax.sql.DataSource;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

@Repository
public class JdbcTemplateMemberRepository implements MemberRepository{
    private final JdbcTemplate jdbcTemplate;

    @Autowired
    public JdbcTemplateMemberRepository(DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

    @Override
    public Optional<Member> findById(Long id) {
        List<Member> result = jdbcTemplate.query("select * from member where id = ?", memberRowMapper(), id);
        return result.stream().findAny();
    }

    @Override
    public Optional<Member> findByName(String name) {
        List<Member> result = jdbcTemplate.query("select * from member where name = ?", memberRowMapper()), name;
        return result.stream().findAny();
    }

		@Override
    public List<Member> findAll() {
        return jdbcTemplate.query("select * from member", memberRowMapper());
    }

    ...

    private RowMapper<Member> memberRowMapper() {
        return (rs, rowNum) -> {
            Member member = new Member();
            member.setId(rs.getLong("id"));
            member.setName(rs.getString("name"));
            return member;
        };
    }
}

package hello.hellospring.repository;

import hello.hellospring.domain.Member;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.simple.SimpleJdbcInsert;
import org.springframework.stereotype.Repository;

import javax.sql.DataSource;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

@Repository
public class JdbcTemplateMemberRepository implements MemberRepository{
    private final JdbcTemplate jdbcTemplate;

    @Autowired
    public JdbcTemplateMemberRepository(DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

    @Override
    public Member save(Member member) {
        SimpleJdbcInsert jdbcInsert = new SimpleJdbcInsert(jdbcTemplate);
        jdbcInsert.withTableName("member").usingGeneratedKeyColumns("id");

        Map<String, Object> parameters = new HashMap<>();
        parameters.put("name", member.getName());

        Number key = jdbcInsert.executeAndReturnKey(new MapSqlParameterSource(parameters));
        member.setId(key.longValue());
        
        return member;
    }

		...

}

JdbcTemplate을 이용하면, insert 작업은 테이블 명과 컬럼 명만 알고 있으면 쿼리를 따로 짤 필요가 없다. save 메서드에 쓰인 위 코드는 참고만 한다.


@Configuration + @Bean을 이용하여 JdbcTemplate 리포지토리를 스프링 빈으로 등록하기

지금까지 또 다른 MemberRepository 인터페이스의 구현체를 만들었다. 이를 스프링이 알 수 있도록 스프링 빈으로 등록해주기 위해서는 SpringConfig.java에서 빈임을 알려주도록 수정해야 한다.

package hello.hellospring;

import hello.hellospring.repository.JdbcMemberRepository;
import hello.hellospring.repository.JdbcTemplateMemberRepository;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
import hello.hellospring.service.MemberService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.sql.DataSource;

@Configuration
public class SpringConfig {
    @Autowired
    private DataSource dataSource2;

    public SpringConfig(DataSource dataSource) {
        this.dataSource2 = dataSource;
    }

    @Bean
    public MemberService memberService() {
        return new MemberService(memberRepository());
    }

    @Bean
    public MemberRepository memberRepository() {
//        return new JdbcMemberRepository(dataSource2);
        return new JdbcTemplateMemberRepository(dataSource2);
    }
}

JdbcTemplateRepository 결과 확인 → 테스트

일일이 웹 애플리케이션을 띄울 필요 없이 테스트 코드를 돌리기만 해도 된다.

 

반응형

'TIL' 카테고리의 다른 글

2023-11-04-TIL | Spring  (0) 2023.11.04
2023-11-01-TIL | Spring  (1) 2023.11.03
2023-10-30-TIL  (2) 2023.10.31
2023-10-28-TIL  (1) 2023.10.31
2023-10-27-TIL  (1) 2023.10.27