단위 테스트

mangoo·2024년 1월 4일
0

책 '단위테스트'의 7장을 정리한 포스팅이다.


코드의 유형

도메인 모델과 알고리즘

회귀 방지가 향상되고 협력자가 거의 없기 때문에 단위 테스트하기 좋은 코드이다.

회귀 방지란?
회귀가 소프트웨어 버그를 의미하며, 회귀 방지는 코드를 수정한 후 버그가 발생하지 않고 의도한 대로 기능이 동작하는 것을 의미한다.

간단한 코드

테스트의 가치가 없다.

컨트롤러

복잡하거나 비즈니스에 중요한 작업을 하는 것이 아니라 도메인 클래스와 외부 애플리케이션 같은 다른 구성 요소의 작업을 조정한다(오케스트레이션). 또한, 포괄적인 통합 테스트의 일부로서 간단히 테스트해야 한다.

지나치게 복잡한 코드

단위 테스트하기 어렵지만 테스트 커버리지 없이 내버려두는 것은 너무 위험하다. 따라서, 도메인 모델과 알고리즘, 컨트롤러 두 부분으로 나누어지도록 리팩토링해야 한다. 리팩토링을 통해 비즈니스 로직과 오케스트레이션을 분리하면 테스트 용이성이 좋을 뿐 아니라 코드 복잡도를 해결하고 프로젝트 성장에 중요한 역할을 한다.

그렇다면 지나치게 복잡한 코드는 어떻게 리팩토링 할 수 있을까?


험블 객체 패턴

험블 객체 패턴은 비즈니스 로직을 오케스트레이션과 같이 다른 모든 것과 분리하기 위한 패턴으로, 지나치게 복잡한 코드를 분할하기 위해 사용할 수 있다. 예제를 살펴보자.

예제

고객 관리 시스템을 개발한다고 가정하자. 모든 사용자는 데이터베이스에 저장되며 현재 이 예제에서 다룰 유스케이스는 사용자가 이메일을 변경하는 상황이다. 비즈니스 규칙은 아래와 같이 세 가지가 있다.

  • 사용자 이메일이 회사 도메인에 속한 경우 해당 사용자는 직원으로 표시되고, 그렇지 않으면 고객으로 간주한다.
  • 시스템은 회사의 직원 수를 추적해야 한다. 사용자 유형이 직원에서 고객으로, 또는 그 반대로 변경되면 이 숫자도 변경해야 한다.
  • 이메일이 변경되면 시스템은 메시지 버스로 메시지를 보내 외부 시스템에 알려야 한다.

처음 작성된 코드는 아래와 같다.

// User.class
@AllArgsConstructor @Getter
public class User {

  private int userId;
  private String email;
  private UserType type;

  public void changeEmail(int userId, String newEmail) {
    Object[] data = Database.getUserById(userId);
    this.userId = userId;
    this.email = (String) data[1];
    this.type = (UserType) data[2];

    if (Objects.equals(email, newEmail)) {
      return;
    }

    Object[] companyData = Database.getCompany();
    String companyDomainName = (String) companyData[0];
    int numberOfEmployees = (int) companyData[1];

    String emailDomain = newEmail.split("@")[1];
    boolean isEmailCorporate = Objects.equals(emailDomain, companyDomainName);
    UserType newType = isEmailCorporate ? UserType.Employee : UserType.Customer;

    if (type != newType) {
      int delta = newType == UserType.Employee ? 1 : -1;
      int newNumber = numberOfEmployees + delta;
      Database.saveCompany(newNumber);
    }

    this.email = newEmail;
    this.type = newType;

    Database.saveUser(this);
    MessageBus.sendEmailChangedMessage(userId, newEmail);
  }
}

앞서 정의한 비즈니스 규칙이 모두 들어있다는 점에서 도메인 유의성은 높다. 그러나 이 클래스는 활성 레코드 패턴으로 비즈니스 로직과 외부 의존성과의 통신 사이에 분리가 없다는 문제가 있다. 도메인 클래스는 프로세스 외부 협력자를 사용하면 안 되지만, DatabaseMessageBus에 의존한다. 그래서 현재 User 클래스는 아래 사분면 중 '지나치게 복잡한 코드' 사분면에 위치해 있다.

활성 레코드 패턴이란?
도메인 클래스가 스스로 데이터베이스를 검색하고 다시 저장하는 방식

나머지 코드는 아래와 같다.

// UserType.java
public enum UserType {
  Customer(1), Employee(2);

  private final intvalue;

  UserType(intvalue) {
    this.value = value;
  }

  public intgetValue() {
    returnvalue;
  }
}

// Database.java
public class Database {

  private static final Map < Integer, User > userMap = new HashMap < > ();
  private static final String companyDomainName = "mangoo.dev";
  private static int numberOfEmployees = 0;

  public static Object[] getUserById(int userId) {
    User user = userMap.get(userId);
    return new Object[] {
      user.getUserId(), user.getEmail(), user.getType()
    };
  }

  public static Object[] getCompany() {
    return new Object[] {
      companyDomainName,
      numberOfEmployees
    };
  }

  public static void saveCompany(int newNumber) {
    numberOfEmployees = newNumber;
  }

  public static void saveUser(User user) {
    userMap.put(user.getUserId(), user);
  }
}

// MessageBus.java
public class MessageBus {
  public static void sendEmailChangedMessage(int userId, String newEmail) {}
}

예제 리팩토링 #1

User 클래스의 문제를 해결해보자. 예제에서 발견한 문제점은 도메인 모델이 프로세스 외부 의존성과의 분리가 이루어지지 않았다는 점이다. 그렇다면 DatabaseMessageBus을 인터페이스로 만들면 되지 않을까?

인터페이스를 통해 참조해도 해당 의존성은 여전히 프로세스 외부에 있고, 이를 테스트하려면 복잡한 목 체계가 필요하다. 따라서, 도메인 모델이 직접적으로든 간접적으로든 인터페이스를 통해 프로세스 외부 협력자에게 의존하지 않는 것이 훨씬 깔끔하다.

도메인 모델이 외부 시스템과 직접 통신하는 문제를 극복하려면 다른 클래스인 험블 컨트롤러로 책임을 옮겨야 한다.

// User.java
@AllArgsConstructor @Getter
public class User {

  private int userId;
  private String email;
  private UserType type;

  public int changeEmail(String newEmail, String companyDomainName, int numberOfEmployees) {
    if (Objects.equals(email, newEmail)) {
      return numberOfEmployees;
    }

    String emailDomain = newEmail.split("@")[1];
    boolean isEmailCorporate = Objects.equals(emailDomain, companyDomainName);
    UserType newType = isEmailCorporate ? UserType.Employee : UserType.Customer;

    if (type != newType) {
      int delta = newType == UserType.Employee ? 1 : -1;
      int newNumber = numberOfEmployees + delta;
      numberOfEmployees = newNumber;
    }

    this.email = newEmail;
    this.type = newType;

    return numberOfEmployees;
  }
}

// UserController.java
public class UserController {

  private final Database database = new Database();
  private final MessageBus messageBus = new MessageBus();

  public void changeEmail(int userId, String newEmail) {
    Object[] data = database.getUserById(userId);
    String email = (String) data[1];
    UserType type = (UserType) data[2];
    User user = new User(userId, email, type);

    Object[] companyData = database.getCompany();
    String companyDomainName = (String) companyData[0];
    int numberOfEmployees = (int) companyData[1];

    int newNumberOfEmployees = user.changeEmail(
      newEmail, companyDomainName, numberOfEmployees
    );

    database.saveCompany(newNumberOfEmployees);
    database.saveUser(user);
    messageBus.sendEmailChangedMessage(userId, newEmail);
  }
}

도메인 클래스는 외부 협력자와의 통신에서 자유로워졌다. 그러나, 애플리케이션 서비스인 UserController에 서 문제점을 발견할 수 있다.

  • 애플리케이션 서비스의 역할은 복잡도나 도메인 유의성의 로직이 아니라 오케스트레이션만 해당하는데, 데이터베이스에서 받은 원시 데이터를 User 인스턴스로 재구성하는 로직이 포함된다.
  • User가 업데이트된 직원 수를 반환하는데, 회사 직원 수는 특정 사용자와 관련이 없다. 책임이 다른 곳으로 이동되어야 한다.
  • 새로운 이메일이 전과 다른지 여부와 관계없이 무조건 데이터를 수정해 저장하고 메시지 버스에 알림을 보낸다.

따라서, UserController는 컨트롤러 사분면에, User는 도메인 모델 사분면에 위치한다.

예제 리팩토링 #2

애플리케이션 서비스인 UserController는 컨트롤러 사분면에 위치해 있지만 데이터베이스에서 받은 원시 데이터를 User 인스턴스로 재구성하는 로직이 포함되어 있어 복잡도가 높다. 이를 낮추기 위해서 ORM 라이브러리를 사용하거나 팩토리 클래스를 작성하는 방식을 택할 수 있다.

이 예제에서는 UserFactory 클래스를 작성했다.

// UserFactory.java
public class UserFactory {

  public static User create(Object[] data) {
    assert data.length >= 3;

    int id = (int) data[0];
    String email = (String) data[1];
    UserType type = (UserType) data[2];

    return new User(id, email, type);
  }
}

// UserController.java
 public class UserController {

   private final Database database = new Database();
   private final MessageBus messageBus = new MessageBus();

   public void changeEmail(int userId, String newEmail) {
     Object[] data = database.getUserById(userId);
     User user = UserFactory.create(data);

     Object[] companyData = database.getCompany();
     String companyDomainName = (String) companyData[0];
     int numberOfEmployees = (int) companyData[1];

     int newNumberOfEmployees = user.changeEmail(
       newEmail, companyDomainName, numberOfEmployees
     );

     database.saveCompany(newNumberOfEmployees);
     database.saveUser(user);
     messageBus.sendEmailChangedMessage(userId, newEmail);
   }
 }
 
 // User.java
 @AllArgsConstructor @Getter
public class User {

  private int userId;
  private String email;
  private UserType type;

  public int changeEmail(String newEmail, String companyDomainName, int numberOfEmployees) {
    if (Objects.equals(email, newEmail)) {
      return numberOfEmployees;
    }

    String emailDomain = newEmail.split("@")[1];
    boolean isEmailCorporate = Objects.equals(emailDomain, companyDomainName);
    UserType newType = isEmailCorporate ? UserType.Employee : UserType.Customer;

    if (type != newType) {
      int delta = newType == UserType.Employee ? 1 : -1;
      int newNumber = numberOfEmployees + delta;
      numberOfEmployees = newNumber;
    }

    this.email = newEmail;
    this.type = newType;

    return numberOfEmployees;
  }
}

팩토리 클래스에게 인스턴스를 생성하는 책임을 위임했기 때문에 복잡도는 낮아졌다. 그러나, changeEmail()을 보면 User 클래스에서 직원수를 업데이트하고 반환하는 책임을 갖고 있다. 이는 책임을 잘못 뒀다는 신호이자 추상화가 없다는 신호이다. 따라서, 직원 수 관리에 대한 책임을 새로운 클래스로 옮길 수 있다.

예제 리팩토링 #3

직원 수를 관리하는 Company 클래스를 생성해 책임을 분리할 수 있다.

// Company.java
@AllArgsConstructor @Getter
public class Company {

  private String domainName;
  private int numberOfEmployees;

  public void changeNumberOfEmployees(int delta) {
    assert numberOfEmployees + delta >= 0;
    numberOfEmployees += delta;
  }

  public boolean isEmailCorporate(String email) {
    String emailDomain = email.split("@")[1];
    return Objects.equals(emailDomain, domainName);
  }
}

// CompanyFactory.java
public class CompanyFactory {

  public static Company create(Object[] data) {
    assert data.length >= 2;

    String domainName = (String) data[0];
    int numberOfEmployees = (int) data[1];

    return new Company(domainName, numberOfEmployees);
  }
}

// UserController.java
public class UserController {

  private final Database database = new Database();
  private final MessageBus messageBus = new MessageBus();

  public void changeEmail(int userId, String newEmail) {
    Object[] data = database.getUserById(userId);
    User user = UserFactory.create(data);

    Object[] companyData = database.getCompany();
    Company company = CompanyFactory.create(companyData);

    user.changeEmail(newEmail, company);

    database.saveCompany(company);
    database.saveUser(user);
    messageBus.sendEmailChangedMessage(userId, newEmail);
  }
}

// User.java
@AllArgsConstructor @Getter
public class User {

  private int userId;
  private String email;
  private UserType type;

  public void changeEmail(String newEmail, Company company) {
    if (Objects.equals(email, newEmail)) {
      return;
    }

    UserType newType = company.isEmailCorporate(newEmail) ? UserType.Employee : UserType.Customer;

    if (type != newType) {
      int delta = newType == UserType.Employee ? 1 : -1;
      company.changeNumberOfEmployees(delta);
    }

    this.email = newEmail;
    this.type = newType;
  }
}

Company 클래스는 이메일이 회사 이메일인지를 결정하는 작업과 회사의 직원 수를 변경하는 작업을 담당한다. 이를 통해 최종적으로 UserController는 컨트롤러 사분면에, Company, CompanyFactory, UserFactory, User는 모두 도메인 모델 사분면에 위치하게 된다.


단위 테스트 작성

User 클래스 테스트는 아래와 같이 작성할 수 있다.

class UserTest {

  @Test
  public void change_email_from_non_corporate_to_corporate() {
    Company company = new Company("mangoo.org", 1);
    User sut = new User(1, "user@gmail.com", UserType.Customer);

    sut.changeEmail("new@mangoo.org", company);

    assertThat(company.getNumberOfEmployees()).isEqualTo(2);
    assertThat(sut.getEmail()).isEqualTo("new@mangoo.org");
    assertThat(sut.getType()).isEqualTo(UserType.Employee);
  }

  @Test
  public void change_email_from_corporate_to_non_corporate() {
    ...
  }

  @Test
  public void change_email_without_changing_user_type() {
    ...
  }

  @Test
  public void change_email_to_same_one() {
    ...
  }
}

0개의 댓글