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
- Inject all dependencies - No hidden coupling
- Small, focused protocols - Easy to mock
- Clear error types - Testable failure modes
- Single responsibility - Coordinate, don't implement