꿈돌이랜드

테스터블한 코드를 작성하는 법 본문

Programming/Swift

테스터블한 코드를 작성하는 법

loinsir 2026. 3. 16. 21:57
반응형

최근 테스트 코드에 대해 공부하고 있는데, 결국 테스트 코드를 작성하기 위해선, 테스트 하려는 코드 자체가 테스트가능한 구조여야 가능하다. 그러면 어떻게 해야 처음부터 테스트 가능한 구조로 코드를 작성하는 습관을 들일 수 있을까? 에 초점을 맞춰 이 글을 작성했다.


1. 의존성을 직접 생성하지 말고, 외부에서 주입받아라

테스트 불가능한 코드의 가장 흔한 원인이다. 객체 내부에서 의존성을 직접 생성하면, 테스트 시 그 의존성을 교체할 수 없다.

AS-IS

class ProfileViewModel {
    private let repository = UserRepository()
    private let analytics = AnalyticsManager.shared

    func loadProfile(userId: String) async -> UserProfile? {
        analytics.track("profile_viewed")
        return await repository.fetchUser(id: userId)
    }
}

UserRepository()를 직접 생성하고, AnalyticsManager.shared라는 싱글턴에 직접 접근한다. 테스트에서 네트워크 호출을 막을 수도, 분석 이벤트를 끌 수도 없다.

TO-BE

class ProfileViewModel {
    private let repository: UserRepositoryProtocol
    private let analytics: AnalyticsProtocol

    init(
        repository: UserRepositoryProtocol,
        analytics: AnalyticsProtocol
    ) {
        self.repository = repository
        self.analytics = analytics
    }

    func loadProfile(userId: String) async -> UserProfile? {
        analytics.track("profile_viewed")
        return await repository.fetchUser(id: userId)
    }
}

생성자에서 프로토콜 타입으로 주입받는다. 프로덕션에서는 실제 구현체를, 테스트에서는 스텁을 넣으면 된다. 코드를 한 줄도 바꾸지 않고 의존성을 교체할 수 있다.


2. 순수한 비즈니스 로직을 사이드 이펙트로부터 분리하라

비즈니스 로직 안에서 네트워크 호출, DB 저장, 알림 발송 등이 섞여 있으면, 로직만 따로 테스트할 방법이 없다. 결정을 내리는 코드결정을 실행하는 코드를 물리적으로 나눠라.

AS-IS

class CouponService {
    private let repository: CouponRepository
    private let pushManager: PushNotificationManager

    func applyCoupon(code: String, to order: Order) async throws -> Order {
        let coupon = try await repository.findByCode(code)

        guard coupon.expiresAt > Date() else {
            throw CouponError.expired
        }

        guard order.totalPrice >= coupon.minimumAmount else {
            throw CouponError.minimumNotMet
        }

        let discounted: Int
        switch coupon.type {
        case .fixedAmount(let amount):
            discounted = max(order.totalPrice - amount, 0)
        case .percentage(let rate):
            discounted = Int(Double(order.totalPrice) * (1.0 - rate))
        }

        var updated = order
        updated.totalPrice = discounted
        updated.appliedCoupon = coupon

        try await repository.markAsUsed(coupon)
        await pushManager.send("쿠폰이 적용되었습니다!")

        return updated
    }
}

할인 계산 로직만 테스트하고 싶어도 repositorypushManager를 반드시 목으로 만들어야 한다. 비즈니스 규칙이 인프라에 갇혀 있다.

TO-BE

// 순수한 비즈니스 로직 — 사이드 이펙트가 전혀 없다
struct CouponCalculator {
    static func apply(
        coupon: Coupon,
        to order: Order,
        currentDate: Date
    ) -> Result<Order, CouponError> {
        guard coupon.expiresAt > currentDate else {
            return .failure(.expired)
        }

        guard order.totalPrice >= coupon.minimumAmount else {
            return .failure(.minimumNotMet)
        }

        let discounted: Int
        switch coupon.type {
        case .fixedAmount(let amount):
            discounted = max(order.totalPrice - amount, 0)
        case .percentage(let rate):
            discounted = Int(Double(order.totalPrice) * (1.0 - rate))
        }

        var updated = order
        updated.totalPrice = discounted
        updated.appliedCoupon = coupon
        return .success(updated)
    }
}

// 사이드 이펙트를 실행하는 셸
class CouponService {
    private let repository: CouponRepository
    private let pushManager: PushNotificationManager

    func applyCoupon(code: String, to order: Order) async throws -> Order {
        let coupon = try await repository.findByCode(code)

        let result = CouponCalculator.apply(
            coupon: coupon,
            to: order,
            currentDate: Date()
        )

        switch result {
        case .success(let updated):
            try await repository.markAsUsed(coupon)
            await pushManager.send("쿠폰이 적용되었습니다!")
            return updated
        case .failure(let error):
            throw error
        }
    }
}

CouponCalculator는 순수한 값 계산이다. 네트워크도, DB도, 시간 의존성도 없다. 입력을 넣으면 출력이 나온다. 비즈니스 규칙이 복잡해질수록 이 분리의 가치는 커진다.


3. 숨은 입력을 제거하라 — 전역 상태와 싱글턴

메서드 시그니처에 드러나지 않는 입력이 있으면 테스트가 어렵다. 대표적인 것이 싱글턴 접근, Date() 직접 생성, UserDefaults 직접 읽기 등이다.

AS-IS

class FeedViewModel {
    func loadFeed() async -> [Post] {
        // 숨은 입력 1: 싱글턴
        let userId = AuthManager.shared.currentUserId

        // 숨은 입력 2: 직접 생성한 시간
        let cutoff = Calendar.current.date(
            byAdding: .day, value: -7, to: Date()
        )!

        // 숨은 입력 3: UserDefaults 직접 접근
        let showAds = UserDefaults.standard.bool(forKey: "showAds")

        let posts = await APIClient.shared.fetchPosts(
            userId: userId, since: cutoff
        )

        if showAds {
            return insertAds(into: posts)
        }
        return posts
    }
}

이 메서드는 파라미터가 없지만, 실제로는 3개의 숨은 입력에 의존한다. 테스트에서 이들을 제어하려면 싱글턴 상태를 조작해야 하고, 이는 테스트 간 공유 상태를 만들어 테스트끼리 영향을 주게 된다.

TO-BE

class FeedViewModel {
    private let apiClient: APIClientProtocol
    private let preferences: PreferencesProtocol

    init(
        apiClient: APIClientProtocol,
        preferences: PreferencesProtocol
    ) {
        self.apiClient = apiClient
        self.preferences = preferences
    }

    func loadFeed(
        userId: String,
        currentDate: Date = Date()
    ) async -> [Post] {
        let cutoff = Calendar.current.date(
            byAdding: .day, value: -7, to: currentDate
        )!

        let posts = await apiClient.fetchPosts(
            userId: userId, since: cutoff
        )

        if preferences.showAds {
            return insertAds(into: posts)
        }
        return posts
    }
}

모든 입력이 명시적이다. userId는 파라미터로, currentDate는 기본값이 있는 파라미터로, apiClientpreferences는 생성자로 주입받는다. 시그니처만 보면 이 메서드가 무엇에 의존하는지 바로 알 수 있다.


4. 하나의 공개 메서드로 하나의 유스케이스를 완결하라

단일한 목표를 달성하기 위해 여러 메서드를 순서대로 호출해야 한다면, 캡슐화에 문제가 있다. 호출 순서를 잘못 지키면 시스템이 모순된 상태에 빠진다(불변 위반).

AS-IS

class OrderProcessor {
    private(set) var order: Order?
    private(set) var isValidated: Bool = false

    func createOrder(items: [Item]) {
        order = Order(items: items)
    }

    func validateStock() throws {
        guard let order else { fatalError() }
        for item in order.items {
            guard item.stock > 0 else {
                throw OrderError.outOfStock(item.name)
            }
        }
        isValidated = true
    }

    func calculateTotal() -> Int {
        guard let order, isValidated else { fatalError() }
        return order.items.reduce(0) { $0 + $1.price }
    }

    func confirm() throws -> Order {
        guard var order, isValidated else { fatalError() }
        order.status = .confirmed
        order.totalPrice = calculateTotal()
        self.order = order
        return order
    }
}

사용하는 쪽에서 createOrdervalidateStockcalculateTotalconfirm 순서를 정확히 지켜야 한다. 순서가 틀리면 런타임 크래시가 발생하고, 테스트도 이 순서를 매번 재현해야 한다.

TO-BE

struct OrderProcessor {
    static func process(items: [Item]) -> Result<Order, OrderError> {
        // 재고 검증
        for item in items {
            guard item.stock > 0 else {
                return .failure(.outOfStock(item.name))
            }
        }

        // 총액 계산 + 주문 확정을 하나의 연산으로
        let total = items.reduce(0) { $0 + $1.price }
        let order = Order(
            items: items,
            totalPrice: total,
            status: .confirmed
        )

        return .success(order)
    }
}

하나의 메서드 호출로 유스케이스가 완결된다. 호출 순서를 신경 쓸 필요가 없고, 중간에 모순된 상태에 빠질 수 없다. 불변 위반이 구조적으로 불가능하다.


5. 프레임워크 의존성을 비즈니스 로직에서 밀어내라

UIKit, CoreLocation, UserNotifications 같은 프레임워크 타입이 비즈니스 로직에 침투하면, 테스트를 위해 프레임워크 환경을 세팅해야 한다.

AS-IS

class NearbyStoreViewModel {
    private let locationManager = CLLocationManager()

    func findStores() -> [Store] {
        // CLLocation에 직접 의존
        guard let location = locationManager.location else {
            return []
        }

        return allStores.filter { store in
            let storeLocation = CLLocation(
                latitude: store.latitude,
                longitude: store.longitude
            )
            // CLLocation의 메서드에 직접 의존
            return location.distance(from: storeLocation) <= 1000
        }
    }
}

CLLocationManager는 시뮬레이터에서 위치를 주지 않을 수도 있고, 권한 설정이 필요하다. 비즈니스 로직("1km 이내의 매장 필터링")을 테스트하고 싶을 뿐인데 프레임워크가 발목을 잡는다.

TO-BE

// 비즈니스 로직에 필요한 최소한의 값 타입 정의
struct Coordinate {
    let latitude: Double
    let longitude: Double

    func distance(to other: Coordinate) -> Double {
        // 하버사인 공식 등으로 거리 계산
        let earthRadius = 6371000.0
        let dLat = (other.latitude - latitude) * .pi / 180
        let dLon = (other.longitude - longitude) * .pi / 180
        let a = sin(dLat/2) * sin(dLat/2) +
                cos(latitude * .pi / 180) * cos(other.latitude * .pi / 180) *
                sin(dLon/2) * sin(dLon/2)
        return earthRadius * 2 * atan2(sqrt(a), sqrt(1-a))
    }
}

// 순수한 비즈니스 로직 — 프레임워크 의존 없음
struct NearbyStoreFilter {
    static func filter(
        stores: [Store],
        from origin: Coordinate,
        maxDistance: Double = 1000
    ) -> [Store] {
        stores.filter { store in
            let storeCoord = Coordinate(
                latitude: store.latitude,
                longitude: store.longitude
            )
            return origin.distance(to: storeCoord) <= maxDistance
        }
    }
}

// 프레임워크 의존은 가장 바깥 계층에서만
class NearbyStoreViewModel {
    private let locationProvider: LocationProviding
    private let storeRepository: StoreRepositoryProtocol

    func findStores() async -> [Store] {
        guard let location = await locationProvider.currentLocation() else {
            return []
        }

        let origin = Coordinate(
            latitude: location.latitude,
            longitude: location.longitude
        )
        let allStores = await storeRepository.fetchAll()

        return NearbyStoreFilter.filter(stores: allStores, from: origin)
    }
}

NearbyStoreFilterCoreLocation을 전혀 모른다. 좌표 두 개와 거리 기준만 있으면 동작한다. 프레임워크는 가장 바깥 껍데기에서만 다루고, 안쪽은 순수한 값과 로직으로 채워라.


6. 구체 타입 대신 프로토콜로 경계를 만들어라

교체 가능한 경계(seam)가 없으면 의존성을 바꿀 수 없다.

AS-IS

class PaymentViewModel {
    // 구체 타입에 직접 의존
    private let paymentGateway: PortOnePaymentGateway

    init() {
        self.paymentGateway = PortOnePaymentGateway(apiKey: "live-key")
    }

    func processPayment(amount: Int) async -> Bool {
        let result = await paymentGateway.charge(amount: amount)
        return result.isSuccess
    }
}

테스트할 때마다 실제 결제가 일어나거나, PortOnePaymentGateway의 구현 변경에 직접적으로 영향받는다.

TO-BE

protocol PaymentGateway {
    func charge(amount: Int) async -> PaymentResult
}

// 프로덕션 구현
struct PortOneGateway: PaymentGateway {
    private let apiKey: String

    init(apiKey: String) {
        self.apiKey = apiKey
    }

    func charge(amount: Int) async -> PaymentResult {
        // 실제 PG 연동
    }
}

class PaymentViewModel {
    private let gateway: PaymentGateway

    init(gateway: PaymentGateway) {
        self.gateway = gateway
    }

    func processPayment(amount: Int) async -> Bool {
        let result = await gateway.charge(amount: amount)
        return result.isSuccess
    }
}

PaymentGateway 프로토콜이 경계 역할을 한다. PG사가 바뀌어도, 테스트 환경이 달라도, PaymentViewModel은 수정할 필요가 없다. 변경될 수 있는 외부 의존성 앞에는 항상 프로토콜 경계를 두어라.


마무리: 테스터블한 프로덕션 코드를 위한 체크리스트

원칙 핵심 질문
의존성 주입 이 객체가 의존성을 직접 생성하고 있지 않은가?
로직과 사이드 이펙트 분리 이 메서드에서 순수한 계산과 외부 호출이 섞여 있지 않은가?
숨은 입력 제거 싱글턴, Date(), UserDefaults에 직접 접근하고 있지 않은가?
유스케이스 완결 하나의 목표를 위해 여러 메서드를 순서대로 호출해야 하지 않은가?
프레임워크 격리 비즈니스 로직에 UIKit, CoreLocation 등이 침투해 있지 않은가?
프로토콜 경계 변경될 수 있는 외부 의존성 앞에 교체 가능한 경계가 있는가?

이 원칙들은 테스트를 위한 것이 아니다. 좋은 설계 그 자체다. 의존성이 명확하고, 로직이 분리되고, 경계가 있는 코드는 테스트 가능성이 자연스럽게 따라온다.

반응형