[testing] Refactoring toward valuable unit tests

Donghoon Bae·2025년 9월 8일

testing

목록 보기
3/6

모든 제품 코드는 다음과같이 크게 2개의 차원으로 분류할 수 있다.

1. 복잡도 또는 도메인 유의성(Complexity or domain significance)
2. 협력자 수(The number of collaborators)

네 가지 코드 유형

1. 도메인 모델과 알고리즘(Domain model and algorithms)
  • 복잡한 코드는 통상 도메인 모델이다.

  • 물론 문제 도메인과 직접적으로 관련이 없는 복잡한 알고리즘이 존재할 수 있다.

2. 간단한 코드(Trivial code)
  • 협력자가 있는 경우가 거의 없는 코드이다.

  • 복잡도나 도메인 유의성도 거의 없다.

3. 컨트롤러(Controllers)
  • 도메인 클래스와 외부 애플리케이션과 같은 다른 구성 요소의 작업을 조정하는 역할을 한다.
4. 지나치게 복잡한 코드(Overcomplicated code)
  • 협력자가 많으며 복잡하고 중요한 비즈니스 로직을 많이 가지고 있는 코드이다.

집중해야 할 부분: Overcomplicated code

1. Hunble Object Pattern
  • 함수형 아키텍쳐는 프로세스 외부 의존성 이외 모든
    협력자와의 커뮤니케이션에서 비즈니스로직을 분리한다.
2. Single Responsibility principle
  • 각 클래스가 하나의 책임만 가져야 한다는 원칙
3. Aggregate Pattern (Domain-Driven Design)
  • 클래스를 클러스터로 묶어서 클래스 간 연결을 줄이는 것.
  • 클래스는 클래스터 내부에 강결합되어있되 클러스터 자체는 느슨하게 결합시키는 것이다.

리팩토링 전

enum UserType {
  case customer
  case employee
}

final class User {
  private(set) var userId: Int = 0
  private(set) var email: String = ""
  private(set) var type: UserType = .customer

  func changeEmail(
  	userId: Int, 
    newEmail: String
  ) {
    let (_, currentEmail, currentType) = Database.getUserById(userId)
    self.userId = userId
    self.email = currentEmail
    self.type = currentType

    if email == newEmail {
      return
    }

    let (companyDomainName, numberOfEmployees) = Database.getCompany()
    let emailDomain = newEmail.split(separator: "@").last.map(String.init) ?? ""
    let isEmailCorporate = emailDomain == companyDomainName
    let newType: UserType = isEmailCorporate ? .employee : .customer

    if type != newType {
      let delta = newType == .employee ? 1 : -1
      let newNumber = numberOfEmployees + delta
      Database.saveCompany(newNumber)
    }

    email = newEmail
    type = newType

    Database.saveUser(self)
    MessageBus.sendEmailChangedMessage(
    	userId: userId, 
        newEmail: newEmail
    )
  }
}

리팩토링 후

struct Company {
  private(set) var domainName: String
  private(set) var numberOfEmployees: Int

  mutating func changeNumberOfEmployees(_ delta: Int) {
    precondition(numberOfEmployees + delta >= 0)
    numberOfEmployees += delta
  }

  func isEmailCorporate(_ email: String) -> Bool {
    guard let emailDomain = email.split(separator: "@").last else { 
    	return false 
    }
    
    return String(emailDomain) == domainName
  }
}

enum CompanyFactory {
  static func create(
  	_ data: (String, Int)
  ) -> Company {
    Company(domainName: data.0, numberOfEmployees: data.1)
  }
}
final class UserController {
  private let database = Database()
  private let messageBus = MessageBus()

  func changeEmail(userId: Int, newEmail: String) {
    let userData = database.getUserById(userId)
    var user = UserFactory.create(userData)

    let companyData = database.getCompany()
    var company = CompanyFactory.create(companyData)

    user.changeEmail(newEmail, company: &company)

    database.saveCompany(company)
    database.saveUser(user)
    messageBus.sendEmailChangedMessage(
    	userId: userId, 
        newEmail: newEmail
    )
  }
}
enum UserType {
  case customer
  case employee
}

struct User {
  private(set) var userId: Int
  private(set) var email: String
  private(set) var type: UserType

  mutating func changeEmail(
  	_ newEmail: String, 
    company: inout Company
  ) {
    if email == newEmail {
      return
    }

    let newType: UserType = company.isEmailCorporate(newEmail) ? .employee : .customer

    if type != newType {
      let delta = newType == .employee ? 1 : -1
      company.changeNumberOfEmployees(delta)
    }

    email = newEmail
    type = newType
  }
}


enum UserFactory {
  static func create(
  	_ data: (Int, String, UserType)
  ) -> User {
    User(userId: data.0, email: data.1, type: data.2)
  }
}

Analysis of optimal unit test coverage

코드복잡도와 도메인 유의성협력자가 거의 없음협력자가 많음
높음User.ChangeEmail(); Company.ChangeNumberOfEmployees(); Company.IsEmailCorporate(); CompanyFactory.create()
낮음User와 Company의 생성자UserController.ChangeEmail()

도메인 계층과 유틸리티 코드 테스트

@Test("Changing email from non-corporate to corporate")
func changingEmailFromNonCorporateToCorporate() {
    let company = Company(domainName: "mycorp.com", numberOfEmployees: 1)
    let sut = User(userId: 1, email: "user@gmail.com", type: .customer)

    sut.changeEmail("new@mycorp.com", company: company)

    #expect(company.numberOfEmployees == 2)
    #expect(sut.email == "new@mycorp.com")
    #expect(sut.type == .employee)
}
func Changing_email_from_corporate_to_non_corporate()
func Changing_email_without_changing_user_type()
func Changing_email_to_the_same_one()

@Test(
	"Differentiates a corporate email from non-corporate",
	arguments: [
		("mycorp.com", "email@mycorp.com", true),
		("mycorp.com", "email@gmail.com", false),
	]
)
func differentiatesCorporateEmail(
	domain: String,
	email: String,
	expectedResult: Bool
) {
	let sut = Company(domain: domain, numberOfEmployees: 0)
	let isCorporate = sut.isEmailCorporate(email)     
	#expect(isCorporate == expectedResult)
}

Handling conditional logic in controllers

비즈니스 로직의 분리는 아래와 같이 비즈니스의 동작이 3단계로 이루어졌을 때 가장 효과적이다.

  • 저장소에서 데이터 검색(Retrieving data from storage)
  • 비즈니스 로직 실행(Executing business logic)
  • 데이터를 다시 저장소에 저장(Persisting data back to the storage)

하지만 현실적으로는 어렵다. 때로는 비즈니스 로직을 수행하는 중간 단계에서 외부 의존성으로부터 추가 데이터를 조회해야 한다거나 하는 상황이 있다고 가정한다.

  1. 도메인 모델 테스트 유의성(Domain model testability)
    도메인 클래스의 협력자 수와 유형에 따른 함수

  2. 컨트롤러의 단순성(Controller simplicity)
    의사 결정 지점이 있느냐 없느냐에 따라 달라질 수 있다.

  3. 성능(Performance)
    프로세스 외부 의존성에 대한 호출 횟수로 정의할 수 있다.

  • 옵션 1: 모든 외부 읽기/쓰기를 가장자리로 밀어내기:
    -- 장점: 컨트롤러 단순성과 도메인 모델 테스트 용이성을 유지.
    -- 단점: 성능을 희생. 필요 없는 경우에도 외부 의존성을 호출.

  • 옵션 2: 프로세스 외부 의존성을 도메인 모델에 주입하기:
    -- 장점: 성능과 컨트롤러 단순성을 유지.
    -- 단점: 도메인 모델 테스트 용이성을 심각하게 훼손. 도메인 모델이 Overcomplicated code 가 되어 테스트하기 어려움.

  • 옵션 3: 결정 과정을 더 세분화된 단계로 분할하기:
    -- 장점: 성능과 도메인 모델 테스트 용이성을 유지.
    -- 단점: 컨트롤러 복잡도 증가.

복잡도를 줄이는 법

Using the CanExecute/Execute pattern

struct User {
	private(set) var userId: Int
	private(set) var email: String
	private(set) var type: UserType
	var isEmailConfirmed: Bool
    
	func canChangeEmail() -> String? {
		if isEmailConfirmed {
			return "Can't change a confirmed email"
		}
        return nil
    }
}

Using domain events to track changes in the domain model

애플리케이션에서 정확히 무슨 일이 일어나는지 외부 시스템에 알려야 하기 때문에 이러한 단계들을 아는 것이 중요할지도 모른다.
도메인 모델에서 중요한 변경 사항을 추적하고 비즈니스 연산이 완료된 후 해당 변경 사항을 프로세스 외부 의존성 호출로 변환한다. 도메인 이벤트로 이러한 추적을 구현할 수 있다.

profile
iOS & Swift

0개의 댓글