Testing Mappers with Sub-Mappers: Balancing Private Implementation and Testability

abdul ahad
3 min readJan 22, 2025

--

Photo by White Field Photo on Unsplash

In software development, mapping data between complex and simplified structures is a common challenge. To maintain clean, modular code, developers often break down the mapping process into sub-mappers that handle individual parts of the transformation. However, this raises a crucial question: how do you test the main mapper class and its sub-mappers without compromising code design?

This article provides a detailed guide to effectively test mappers and sub-mappers while maintaining clean architecture, using a real-world example of an e-commerce order system.

Solution Overview

1. Keep Sub-Mappers as Private Implementation Details

If sub-mappers are not intended to be part of the public API, they can remain private. The focus should be on testing the behavior of the main Mapper class without exposing or directly verifying calls to sub-mappers.

Key Practices:

  • Test the Main Mapper’s Output: Ensure the Mapper produces the correct output for given input.
  • Avoid Testing Internal Calls: Do not test whether the Mapper calls sub-mappers explicitly; instead, validate the final output.

2. Test Sub-Mappers Individually

Sub-mappers often encapsulate reusable, specific logic. Testing them individually ensures they behave correctly in isolation. This can be achieved by:

  • Using @testable import: Make sub-mappers internal and access them in your test target using @testable import.
  • Making Sub-Mappers Public: If exposing sub-mappers aligns with the design goals, make them public and write independent tests.

Real-World Example: Mapping an E-Commerce Order

Scenario

We need to map a complex Order object to a simplified OrderSummary for display in a user interface. The Order contains multiple properties, and the mapping process involves:

  • Extracting and formatting item names.
  • Calculating the total price.
  • Formatting the shipping destination.

Models

struct Order {
let items: [OrderItem]
let payment: Payment
let shipping: Shipping
}

struct OrderItem {
let name: String
let price: Double
}
struct Payment {
let method: String
let amount: Double
}
struct Shipping {
let address: String
let city: String
let country: String
}
struct OrderSummary {
let itemNames: String
let totalPrice: Double
let shippingDestination: String
}

Step 1: Create Sub-Mappers

ItemMapper

Handles the mapping of item names.

class ItemMapper {
func mapItems(_ items: [OrderItem]) -> String {
return items.map { $0.name }.joined(separator: ", ")
}
}

PaymentMapper

Calculates the total price.

class PaymentMapper {
func mapTotalPrice(_ payment: Payment) -> Double {
return payment.amount
}
}

ShippingMapper

Formats the shipping destination.

class ShippingMapper {
func mapShippingDestination(_ shipping: Shipping) -> String {
return "\(shipping.address), \(shipping.city), \(shipping.country)"
}
}

Step 2: Create the Main OrderMapper

The OrderMapper uses the sub-mappers internally to keep its logic focused.

class OrderMapper {
private let itemMapper = ItemMapper()
private let paymentMapper = PaymentMapper()
private let shippingMapper = ShippingMapper()

func mapOrder(_ order: Order) -> OrderSummary {
let itemNames = itemMapper.mapItems(order.items)
let totalPrice = paymentMapper.mapTotalPrice(order.payment)
let shippingDestination = shippingMapper.mapShippingDestination(order.shipping)
return OrderSummary(itemNames: itemNames, totalPrice: totalPrice, shippingDestination: shippingDestination)
}
}

Step 3: Test Sub-Mappers Individually

ItemMapper Test

import XCTest

class ItemMapperTests: XCTestCase {
func test_mapItems_formatsItemNames() {
let sut = ItemMapper()
let items = [
OrderItem(name: "Laptop", price: 999.99),
OrderItem(name: "Mouse", price: 19.99)
]
let result = sut.mapItems(items)
XCTAssertEqual(result, "Laptop, Mouse")
}
}

PaymentMapper Test

class PaymentMapperTests: XCTestCase {
func test_mapTotalPrice_returnsTotalAmount() {
let sut = PaymentMapper()
let payment = Payment(method: "Credit Card", amount: 1019.98)

let result = sut.mapTotalPrice(payment)
XCTAssertEqual(result, 1019.98)
}
}

ShippingMapper Test

class ShippingMapperTests: XCTestCase {
func test_mapShippingDestination_formatsAddress() {
let sut = ShippingMapper()
let shipping = Shipping(address: "123 Main St", city: "Springfield", country: "USA")

let result = sut.mapShippingDestination(shipping)
XCTAssertEqual(result, "123 Main St, Springfield, USA")
}
}

Step 4: Test the Main OrderMapper

Test the OrderMapper as a black box to validate its behavior.

class OrderMapperTests: XCTestCase {
func test_mapOrder_createsOrderSummary() {
let sut = OrderMapper()
let order = Order(
items: [
OrderItem(name: "Laptop", price: 999.99),
OrderItem(name: "Mouse", price: 19.99)
],
payment: Payment(method: "Credit Card", amount: 1019.98),
shipping: Shipping(address: "123 Main St", city: "Springfield", country: "USA")
)

let result = sut.mapOrder(order)
XCTAssertEqual(result.itemNames, "Laptop, Mouse")
XCTAssertEqual(result.totalPrice, 1019.98)
XCTAssertEqual(result.shippingDestination, "123 Main St, Springfield, USA")
}
}

Conclusion

By keeping sub-mappers private and testing them individually, you ensure:

  1. Focused Tests: Sub-mappers handle specific logic, making tests modular and easier to maintain.
  2. Clean API: The OrderMapper hides implementation details, presenting a simple interface.
  3. Behavior-Driven Validation: Tests for OrderMapper verify the final behavior without exposing internal structure.

Combining both the solutions, you can create clean, testable code that balances modularity and encapsulation.

--

--

abdul ahad
abdul ahad

Written by abdul ahad

A software developer dreaming to reach the top and also passionate about sports and language learning

No responses yet