[Spring Boot] TDD 도입기

고리·2023년 5월 30일
0

Server

목록 보기
8/12
post-thumbnail

간단한 미니 프로젝트에 TDD를 사용하기로 했다. 별생각 없이 내린 결정이지만 이렇게 귀찮고 또한 효율적일지 몰랐다.

이번 포스팅에서는 TDD를 활용한 회원 가입 서비스를 만들어 보자.


순수 자바를 사용한 Test

코드

package com.example.klas_server.User;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.util.Assert;

import java.util.HashMap;
import java.util.Map;

public class UserServiceTest {
    private UserService userService;
    private UserPort userPort;
    private UserRepository userRepository;

    @BeforeEach
    void setUp() {
        userRepository = new UserRepository();
        userPort = new UserAdapter(userRepository);
        userService = new UserService(userPort);
    }

    @Test
    void 회원가입() {
        final SignUpUserRequest req = 회원가입요청_생성();
        userService.SignUpUser(req);
    }

    private static SignUpUserRequest 회원가입요청_생성() {
        final String name = "이연걸";
        final Integer userId = 2018202076;
        final String password = "password";
        final UserType type = UserType.STUDENT;
        return new SignUpUserRequest(name, userId, password, type);
    }

    private record SignUpUserRequest(String name, Integer userId, String password, UserType userType) {
        SignUpUserRequest {
            Assert.hasText(name, "이름은 필수입니다.");
            Assert.notNull(userId, "id는 필수입니다.");
            Assert.hasText(password, "비밀번호는 필수입니다.");
            Assert.notNull(userType, "사용자 유형은 필수입니다.");
        }
    }

    public enum UserType {
        STUDENT,
        PROFESSOR;
    }

    public class UserService {
        final private UserPort userPort;

        UserService(UserPort userPort) {
            this.userPort = userPort;
        }

        public void SignUpUser(final SignUpUserRequest req) {
            final User user = new User(req.name(), req.userId(), req.password(), req.userType());

            userPort.save(user);
        }
    }

    public class UserRepository {
        private Map<Long, User> persistence = new HashMap<>();
        private Long sequence = 0L;


        public void save(final User user) {
            user.assignId(++sequence);
            persistence.put(user.getId(), user);
        }
    }

    public interface UserPort {
        void save(final User user);
    }

    public class UserAdapter implements UserPort {
        private final UserRepository userRepository;

        UserAdapter(final UserRepository userRepository) {
            this.userRepository = userRepository;
        }

        @Override
        public void save(final User user) {
            userRepository.save(user);
        }
    }

    public class User {
        private Long id;
        private final String name;
        private final Integer userId;
        private final String password;
        private final UserType userType;

        public User(final String name, final Integer userId, final String password, final UserType userType) {
            Assert.hasText(name, "이름은 필수입니다.");
            Assert.notNull(userId, "id는 필수입니다.");
            Assert.hasText(password, "비밀번호는 필수입니다.");
            Assert.notNull(userType, "사용자 유형은 필수입니다.");

            this.name = name;
            this.userId = userId;
            this.password = password;
            this.userType = userType;
        }

        public void assignId(final Long id) {
            this.id = id;
        }

        public Long getId() {
            return id;
        }
    }
}

코드 설명

위 코드에서 사용된 port와 adapter는 헥사고날 아키텍처, 포트 & 어댑터 등으로 불리고 있는 구조에서 말하는 아웃바운드 포트, 아웃바운드 어댑터다.

아웃바운드 포트를 이용해 애플리케이션 코어를 외부(DB, API, 등...)와 분리하여, 애플리케이션 코어를 쉽게 테스트 하고, 외부의 변경으로 부터 코어를 보호하려는 의도로 사용되었다.

헥사고날 아키텍처(Hexagonal Architecture)란?

헥사고날 아키텍처는 도메인 로직과 외부 환경을 분리하여 시스템을 유연하고 확장 가능하게 만들기 위한 목적으로 고안되었다.

이 아키텍처의 핵심 개념은 포트어댑터다.

  • 포트는 시스템과 외부의 인터페이스를 정의하는 추상화다. 이는 시스템 내부의 도메인 로직에 해당하는 코드를 외부와의 결합도를 낮추면서 분리시키는 역할을 한다.
  • 어댑터는 이러한 포트와 외부 시스템 또는 리소스를 연결하는 구현체다. 어댑터는 외부 시스템과의 통신, 데이터 변환, 인터페이스 구현 등을 처리한다.

테스트 순서(flow)

  1. UserService에 SignUpUser(req)라는 요청을 보낸다.
  2. UserSerive에서 User를 생성한다.
  3. 생성된 User를 UserPort에게 저장하라고 시킨다.
  4. UserPort의 구현체인 UserAdapter에서 User를 메모리에 저장한다.

이후에 JPA Repository를 사용하게 되면 UserAdapter를 JPA Repository로 변경하면 된다.

테스트 결과

위에서 생성한 클래스들은 전부 UserServiceTest의 Inner class다. 이들을 전부 밖으로 빼서 test가 아닌 main의 User Package로 만들어주자

class 이름을 클릭하고 F6을 누르면 Select Refactoring 창을 볼 수 있다. 여기서 upper level로 class를 이동시키자.

package com.example.klas_server.User;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

public class UserServiceTest {
    private UserService userService;
    private UserPort userPort;
    private UserRepository userRepository;

    @BeforeEach
    void setUp() {
        userRepository = new UserRepository();
        userPort = new UserAdapter(userRepository);
        userService = new UserService(userPort);
    }

    @Test
    void 회원가입() {
        final SignUpUserRequest req = 회원가입요청_생성();
        userService.SignUpUser(req);
    }

    private static SignUpUserRequest 회원가입요청_생성() {
        final String name = "이연걸";
        final Integer userId = 2018202076;
        final String password = "password";
        final UserType type = UserType.STUDENT;

        return new SignUpUserRequest(name, userId, password, type);
    }
}

밖으로 빼낸 class들을 main의 User 패키지로 옮겨주자


Spring Boot Test

이제 순수 자바로 구현된 회원가입 서비스를 Spring Bean으로 등록하고 Spring Boot Test로 동작하게 만들어 보자

UserService와 UserAdapter class에 @Component 어노테이션을 붙여주자

package com.example.klas_server.User;

import org.springframework.stereotype.Component;

@Component
class UserService {
    private final UserPort userPort;
    UserService(final UserPort userPort) {
        this.userPort = userPort;
    }

    public void SignUpUser(final SignUpUserRequest req) {
        final User user = new User(req.name(), req.userId(), req.password(), req.userType());
        userPort.save(user);
    }
}

package com.example.klas_server.User;

import org.springframework.stereotype.Component;

@Component
class UserAdapter implements UserPort {
    private final UserRepository userRepository;

    UserAdapter(final UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public void save(final User user) {
        userRepository.save(user);
    }
}

코드 설명

@Component란?

@Component은 Class를 Spring의 관리 대상으로 지정하는 역할을 한다. 이 어노테이션이 적용된 클래스는 Spring Application의 컴포넌트 스캔을 통해 자동으로 검색되고, 스프링의 IoC (Inversion of Control) 컨테이너에 의해 관리되게 된다.

@Component은 일반적으로 다른 세부 어노테이션들의 상위 개념으로 사용된다.

  • @Controller: MVC 컨트롤러 Class를 나타낸다.
  • @Service: 비즈니스 로직을 수행하는 Service Class를 나타낸다.
  • @Repository: Data 접근을 담당하는 Repository Class를 나타낸다.
  • @RestController: RESTful 웹 서비스에서 사용되는 Controller Class를 나타낸다.

위의 예시에서 UserService와 UserPort 클래스는 @Component 어노테이션이 적용되어 Spring Context에 등록된다. 이후 다른 Class에서 UserService와 UserPort 클래스를 필요로 할 때, 스프링은 해당 Class에 의존성 주입을 수행해 인스턴스를 제공하게 된다.

UserRepository에는 바로 위에서 설명한 @Repository 어노테이션을 붙여주자.

package com.example.klas_server.User;

import org.springframework.stereotype.Repository;

import java.util.HashMap;
import java.util.Map;

@Repository
class UserRepository {
    private Map<Long, User> persistence = new HashMap<>();
    private Long sequence = 0L;

    public void save(final User user) {
        user.assignId(++sequence);
        persistence.put(user.getId(), user);
    }
}

이제 UserServiceTest에 @SpringBootTest어노테이션을 붙여주자! 방금 Spring Boot 어노테이션들을 활용해 Class들을 spring context(spring bean)에 등록했다. 그러므로 @Autowired 어노테이션을 사용한 필드 주입이 가능해진다.

package com.example.klas_server.User;

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

@SpringBootTest
public class UserServiceTest {
    @Autowired
    private UserService userService;

    @Test
    void 회원가입() {
        final SignUpUserRequest req = 회원가입요청_생성();
        userService.SignUpUser(req);
    }

    private static SignUpUserRequest 회원가입요청_생성() {
        final String name = "이연걸";
        final Integer userId = 2018202076;
        final String password = "password";
        final UserType type = UserType.STUDENT;

        return new SignUpUserRequest(name, userId, password, type);
    }
}

@Autowired란?

@Autowired는 Spring Framework에서 제공하는 어노테이션 중 하나로, 의존성 주입(Dependency Injection)을 자동으로 수행하기 위해 사용된다. @Autowired 어노테이션을 사용하면 스프링은 해당 필드, 생성자, 또는 메서드 파라미터의 종속 객체를 찾아서 자동으로 주입해 준다.

여기서는 UserPort, UserRepository 타입의 객체를 스프링 빈에서 찾아서 userPort, userRepository 필드에 자동으으로 주입해 준다.


API Test

API Test를 위해 gradle에 Rest assured 의존성을 추가해 주자

Rest-assured란?
API를 재활용할 수 있게 해준다. 등록, 수정, 삭제 등을 포함한 API 테스트가 여러 개 있을 때 수정 요청이 들어왔다면 등록을 먼저 하고 수정하는 등의 동작이 가능해진다.

API Test를 작성하자

// ApiTest.java

import io.restassured.RestAssured;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.web.context.WebServerInitializedEvent;
import org.springframework.context.event.EventListener;

@SpringBootTest
public class ApiTest {
    @EventListener
    public void onWebInit(WebServerInitializedEvent event) {
        RestAssured.port = event.getWebServer().getPort();
    }
}

// UserApiTest.java

import com.example.klas_server.ApiTest;
import io.restassured.RestAssured;
import io.restassured.response.ExtractableResponse;
import io.restassured.response.Response;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;

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

@SpringBootTest
public class UserApiTest extends ApiTest {
    @Test
    void SignUp() {
        final var req = 회원가입요청_생성();

        final var response = 회원가입_요청(req);

        assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value());
    }

    private static SignUpUserRequest 회원가입요청_생성() {
        final String name = "이연걸";
        final Integer userId = 2018202076;
        final String password = "password";
        final UserType userType = UserType.STUDENT;

        return new SignUpUserRequest(name, userId, password, userType);
    }

    private static ExtractableResponse<Response> 회원가입_요청(SignUpUserRequest req) {
        return RestAssured.given().log().all()
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .body(req)
                .when()
                .post("/users")
                .then()
                .log().all().extract();
    }
}

사용자의 요청을 받기 위해 Controller를 설정하자

@RestController
@RequestMapping("/users")
class UserService {
    private final UserPort userPort;
    UserService(final UserPort userPort) {
        this.userPort = userPort;
    }

    @PostMapping
    public ResponseEntity<Void> SignUpUser(@RequestBody final SignUpUserRequest req) {
        final User user = new User(req.name(), req.userId(), req.password(), req.userType());
        userPort.save(user);
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }
}

테스트 결과

코드 설명

ApiTest

ApiTest 코드가 상당히 못생겼는데 원래 작성하고 싶었던 코드는 아래와 같았다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ApiTest {
    @LocalServerPort
    private int port;

    @BeforeEach
    void setUp() {
        RestAssured.port = port;
    }
}

api 요청마다 포트 번호를 동적할당 받고 싶었지만 @LocalServerPort로 현재 실행 중인 포트 번호를 주입받는 동작에서 계속 막혔다. 아마 테스트할 때 요청을 보내는 포트번호와 요청을 받는 어플리케이션의 포트 번호가 달라서 문제가 발생한 것 같다. 이걸 해결하려고 하루를 다 버려가면서 이것저것 시도해 봤다.

  • @Value("${local.server.port}") 로 변경하기

  • 방화벽 설정 바꾸기

  • 난수생성기로 임의의 포트번호 지정하기

  • @DynamicPropertySource 로 직접 프로퍼티 등록하기

  • application.properties에서 server.port=0으로 설정해 내장 서버가 임의의 포트 번호를 할당하게 해보기

결론은 다 안 됐다.. 어딘가에 방법은 있을 테지만 도저히 모르겠어서, 디폴트 포트인 8080으로 테스트를 진행한다.

UserApiTest

UserApiTest는 몇 가지 눈여겨볼 특징들이 있다.

  • Inheritance
    포트번호 설정을 위해 ApiTest를 상속받는다.

  • var
    Java 10에서 도입된 var는 변수를 선언할 때 타입을 생략할 수 있으며, 컴파일러가 타입을 추론한다.

  • RestAssured.given()
    RestAssured.given()은 request specification을 설정하기 위한 시작이다.

여기까지 한다면, 사용자가 Http request로 회원가입 요청을 했을 때 사용자 정보를 메모리에 저장한 후 201 상태 코드를 반환해 줄 수 있다.

여기서부터는 사용자 정보를 메모리에 저장하는 것이 아니라 MySQL에 저장할 수 있도록 JPA를 적용한다.


JPA Test

build.gradle에 SQL dependency를 추가해 주자.

application.properties에 MySQL property도 등록해 주자. 이번 프로젝트에서는 MySQL DB를 팀 내에서 공유하기 때문에 AWS RDB를 사용하였다.

MySQL Workbench에 DB 테이블도 만들어 주자.

다음으로는 UserRepository를 JpaRepository로 만들어 주자

package com.example.klas_server.User;

import org.springframework.data.jpa.repository.JpaRepository;

interface UserRepository extends JpaRepository<User, Long> {
}

User class도 Entity로 만들어 주자

@Entity
@Table(name = "users")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(nullable = false)
    private String name;
    @Column(nullable = false, name = "userid")
    private Integer userId;
    @Column(nullable = false)
    private String password;
    @Column(nullable = false, name = "usertype")
    private UserType userType;

    public User(final String name, final Integer userId, final String password, final UserType userType) {
        Assert.hasText(name, "이름은 필수입니다.");
        Assert.notNull(userId, "id는 필수입니다.");
        Assert.hasText(password, "비밀번호는 필수입니다.");
        Assert.notNull(userType, "사용자 유형은 필수입니다.");

        this.name = name;
        this.userId = userId;
        this.password = password;
        this.userType = userType;
    }
}

코드 설명

JpaRepository

여기서는 userRepository 인터페이스가 JpaRepository를 상속받았다. JpaRepository는 DB의 CRUD 작업을 수행하기 위한 공통 인터페이스기 때문에 userRepository는 공통 메서드를 사용해 DB 엑세스 작업을 수행할 수 있게 된다.

@NoArgsConstructor

이 어노테이션을 Class에 적용하면 매개변수가 없는 기본 생성자를 자동으로 생성해 준다.

이렇게 하면 끝이다!

다시 Test를 진행하면 정상적으로 작동한다.

여기까지 API 요청을 보냈을 때 유저 정보가 DB에 등록이 되고 등록이 완료되었다는 응답을 반환하는 테스트를 만들었다.

Test 격리

RestAssured는 한 가지 문제를 갖고 있다. spring boot 어플리케이션을 띄우면 캐싱이 되어 다른 Test에서 유저를 여러 번 등록하거나 수정하면 테스트가 꼬일 수 있다는 것이다. 이를 해결하기 위해 Test case마다 데이터베이스를 초기화 시켜주는 코드를 추가하자.

build.gradle에 아래 의존성을 추가하자.

implementation 'com.google.guava:guava:32.0.0-jre'

ApiTest.java와 같은 위치에 DatabaseCleanup.java를 생성하자.

package com.example.klas_server;

import com.google.common.base.CaseFormat;
import jakarta.annotation.PostConstruct;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.metamodel.EntityType;
import jakarta.persistence.Table;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

@Component
public class DatabaseCleanup {
    @PersistenceContext
    private EntityManager entityManager;

    private List<String> tableNames;

    @PostConstruct
    public void setTableNames() {
        final Set<EntityType<?>> entities = entityManager.getMetamodel().getEntities();
        tableNames = entities.stream()
                .filter(e -> isEntity(e) && hasTableAnnotation(e))
                .map(e -> e.getJavaType().getAnnotation(Table.class).name())
                .collect(Collectors.toList());

        final List<String> entityNames = entities.stream()
                .filter(e -> isEntity(e) && !hasTableAnnotation(e))
                .map(e -> CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, e.getName()))
                .toList();
        tableNames.addAll(entityNames);
    }

    private boolean isEntity(final EntityType<?> e) {
        return null != e.getJavaType().getAnnotation(Entity.class);
    }

    private boolean hasTableAnnotation(final EntityType<?> e) {
        return null != e.getJavaType().getAnnotation(Table.class);
    }

    @Transactional
    public void execute() {
        entityManager.flush();
        entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 0").executeUpdate();

        for (final String tableName : tableNames) {
            entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
            entityManager.createNativeQuery("ALTER TABLE " + tableName + " AUTO_INCREMENT = 1").executeUpdate();
        }

        entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate();
    }
}

코드 설명

@PersistenceContext

이 어노테이션은 JPA를 사용해 DB와 상호작용할 때 사용되는 EntityManager를 주입하는 데에 사용된다. 위 코드처럼 entityManager 필드에 @PersistenceContext 어노테이션을 적용하면 entityManager 필드에 EntityManager가 주입된다. 이를 통해 DB와 상호작용할 수 있다.

setTableNames

  • @PostConstruct

    이 어노테이션이 적용된 setTableNames 메서드는 DatabaseCleanup Class의 인스턴스가 생성된 후에 자동으로 호출된다. 이 메서드는 클래스의 생성자와 DI(Dependency injection)이 완료된 이후에 실행되므로, 클래스의 초기화 작업에 적합하다.

    그 때문에 주로 DB connection 설정, 리소스, 캐시 초기화 등에 사용된다. 또한 위 코드처럼 @Component 어노테이션 처럼 class를 Spring context에 등록하는 어노테이션과 함께 사용되어 Spring container에서 해당 bean을 자동으로 인식하고 초기화할 수 있다.

  • tableNames

    entityManager로부터 entity들을 가져온다. 이 entity들을 stream으로 보내서 @Entity 어노테이션이 붙어 있고, @Table 어노테이션이 붙어 있는지 확인한다.

    이것처럼 말이다. 조건을 만족하는 Entity의 table에 대한 이름을 가져와서 list에 담는다.

    반면 @Entity가 붙어 있지만, @Table 어노테이션이 없는 Entity는 파스칼 케이스에서 스네이크 케이스로 바꿔서 리스트에 넣어준다.
    ex) UserName = user_name

execute

  • @Transactional

    이 어노테이션이 적용된 메서드 내에서 실행되는 모든 DB 관련 작업은 하나의 트랜잭션으로 묶이게 된다. 즉, 메서드 실행 전에 트랜잭션을 시작하고, 메서드 실행이 완료되면 트랜잭션을 커밋하거나 롤백하는 등의 트랜잭션 관리 작업을 수행하는 것이다.

    이를 통해 DB 작업의 원자성(Atomicity)을 보장하고, 예외가 발생한 경우는 롤백하여 이전 상태로 복원할 수 있다.

  • execute

    특정 Table의 row를 지우기 위해서는 TRUNCATE TABLE 명령어를 사용하면 된다. 하지만 그 Table이 다른 테이블과 Foreign Key로 연결되어 있으면 참조 무결성으로 인해 지워지지 않는다. 이것을 방지하기 위해 SET FOREIGN_KEY_CHECKS = 0 SQL 문을 사용하였다.

    row를 지우고 난 후 테이블의 id 컬럼이 AUTO_INCREMENT를 1부터 다시 시작하기 위해 ALTER TABLE 테이블이름 AUTO_INCREMENT = 1 SQL문을 사용하였다.

이제 모든 Api Test에서 DB를 초기화 시켜줄 수 있게 ApiTest를 변경해 주자

package com.example.klas_server;

import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class ApiTest {
    @Autowired
    private DatabaseCleanup databaseCleanup;

    @BeforeEach
    void setUp() {
        if (RestAssured.port == RestAssured.UNDEFINED_PORT) {
            RestAssured.port = 8080;
            databaseCleanup.setTableNames();
        }
        databaseCleanup.execute();
    }
}

정리

지금까지 한 작업은 다음과 같은 흐름으로 이루어졌다.

POJO Test 작성 -> Spring Boot Test작성 -> API Test 작성 -> JPA 변경

개인적으로 중요하다고 생각한 점은 POJO Test를 작성하는 과정에서 자연스럽게 서비스 코드 작성이 이루어졌다는 것이다. 이렇게 되면 이 코드는 당연하게도 테스트 가능한코드가 된다.

살짝 아쉬운 점은 테스트가 하나뿐이라는 것이다.

이제부터 해야 할 일이 바로 다른 기능의 POJO Test 작성 때 여러 예외 케이스를 같이 작성하는 것이 아닐까?!

끝!

profile
Back-End Developer

0개의 댓글