Testing Mastery

Building Testable Service Layers

April 19, 2026
6 min read
Featured image for blog post: Building Testable Service Layers

A service layer coordinates multiple dependencies. Here's how to design one that's easy to test.

Follow along with the code: iOS-Practice on GitHub

The OrderService Example

This service coordinates payment processing, email sending, and notification scheduling:

class OrderService {
    private let paymentProcessor: PaymentProcessor
    private let emailService: EmailService
    private let notificationScheduler: NotificationScheduler

    init(
        paymentProcessor: PaymentProcessor,
        emailService: EmailService,
        notificationScheduler: NotificationScheduler
    ) {
        self.paymentProcessor = paymentProcessor
        self.emailService = emailService
        self.notificationScheduler = notificationScheduler
    }

    func placeOrder(
        amount: Decimal,
        currency: String,
        cardToken: String,
        customerEmail: String
    ) async throws -> OrderResult {
        // 1. Process payment
        let paymentResult = try await paymentProcessor.processPayment(
            amount: amount,
            currency: currency,
            cardToken: cardToken
        )

        guard case .success = paymentResult.status else {
            if case .declined(let reason) = paymentResult.status {
                throw OrderError.paymentDeclined(reason)
            }
            throw OrderError.paymentFailed
        }

        // 2. Send confirmation email
        try await emailService.sendEmail(
            to: customerEmail,
            subject: "Order Confirmed",
            body: "Your order for \(currency) \(amount) has been confirmed."
        )

        // 3. Schedule delivery notification
        let deliveryDate = Calendar.current.date(byAdding: .day, value: 1, to: Date())!
        let notificationId = try notificationScheduler.scheduleNotification(
            title: "Delivery Update",
            body: "Your order is out for delivery!",
            at: deliveryDate
        )

        return OrderResult(
            orderId: UUID().uuidString,
            transactionId: paymentResult.transactionId,
            notificationId: notificationId
        )
    }
}

Testing Setup

With all dependencies injected, testing is straightforward:

class OrderServiceTests: XCTestCase {
    var mockPayment: MockPaymentProcessor!
    var mockEmail: MockEmailService!
    var mockNotifications: MockNotificationScheduler!
    var sut: OrderService!

    override func setUp() {
        mockPayment = MockPaymentProcessor()
        mockEmail = MockEmailService()
        mockNotifications = MockNotificationScheduler()

        sut = OrderService(
            paymentProcessor: mockPayment,
            emailService: mockEmail,
            notificationScheduler: mockNotifications
        )
    }

    override func tearDown() {
        mockPayment.reset()
        mockEmail.reset()
        mockNotifications.reset()
    }
}

Testing the Happy Path

func test_placeOrder_processesPayment() async throws {
    _ = try await sut.placeOrder(
        amount: 99.99,
        currency: "USD",
        cardToken: "tok_123",
        customerEmail: "test@example.com"
    )

    XCTAssertEqual(mockPayment.processPaymentCalls.count, 1)
    XCTAssertEqual(mockPayment.processPaymentCalls[0].amount, 99.99)
}

func test_placeOrder_sendsEmail() async throws {
    _ = try await sut.placeOrder(
        amount: 50.00,
        currency: "USD",
        cardToken: "tok_123",
        customerEmail: "customer@test.com"
    )

    XCTAssertEqual(mockEmail.sendEmailCallCount, 1)
    XCTAssertTrue(mockEmail.verifySendEmailCalled(with: "customer@test.com"))
}

func test_placeOrder_schedulesNotification() async throws {
    let result = try await sut.placeOrder(
        amount: 100,
        currency: "USD",
        cardToken: "tok_123",
        customerEmail: "test@example.com"
    )

    XCTAssertTrue(mockNotifications.hasNotification(withId: result.notificationId))
}

Testing Error Paths

When payment fails, subsequent steps shouldn't run:

func test_placeOrder_whenPaymentDeclined_throwsError() async {
    mockPayment.setDeclinedResponse(reason: "Insufficient funds")

    do {
        _ = try await sut.placeOrder(...)
        XCTFail("Expected error")
    } catch OrderError.paymentDeclined(let reason) {
        XCTAssertEqual(reason, "Insufficient funds")
    } catch {
        XCTFail("Wrong error type: \(error)")
    }
}

func test_placeOrder_whenPaymentDeclined_doesNotSendEmail() async {
    mockPayment.setDeclinedResponse(reason: "Card expired")

    _ = try? await sut.placeOrder(...)

    XCTAssertEqual(mockEmail.sendEmailCallCount, 0)
}

func test_placeOrder_whenPaymentDeclined_doesNotScheduleNotification() async {
    mockPayment.setDeclinedResponse(reason: "Card expired")

    _ = try? await sut.placeOrder(...)

    XCTAssertTrue(mockNotifications.getPendingNotifications().isEmpty)
}

Design Principles

  1. Inject all dependencies - No hidden coupling
  2. Small, focused protocols - Easy to mock
  3. Clear error types - Testable failure modes
  4. Single responsibility - Coordinate, don't implement

Originally published on pixelper.com

© 2026 Christopher Moore / Dead Pixel Studio

Let's work together

Professional discovery, design, and complete technical coverage for your ideas

Get in touch