Testing Mappers with Sub-Mappers: Balancing Private Implementation and Testability
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-mappersinternal
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:
- Focused Tests: Sub-mappers handle specific logic, making tests modular and easier to maintain.
- Clean API: The
OrderMapper
hides implementation details, presenting a simple interface. - 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.