Reputation: 16715
I'm building a mortgage calculator as an exercise to learn Combine
. Everything has been going swimmingly until I encountered a situation where I'm not getting deterministic published output from one of my Publishers
when I unit test it. I'm not making any asynchronous calls. This is the problematic AnyPublisher
:
public lazy var monthlyPayment: AnyPublisher<Double, Never> = {
Publishers.CombineLatest3(financedAmount, monthlyRate, numberOfPayments)
.print("montlyPayment", to: nil)
.map { financedAmount, monthlyRate, numberOfPayments in
let numerator = monthlyRate * pow((1 + monthlyRate), Double(numberOfPayments))
let denominator = pow((1 + monthlyRate), Double(numberOfPayments)) - 1
return financedAmount * (numerator / denominator)
}
.eraseToAnyPublisher()
}()
Let's say I change the mortgage type from a 30 year to a 15 year, a few things happen:
numberOfPayments
changes due to the change in the mortgage term (length)monthlyRate
due to a change in the mortgage term (length)My end goal is to wait for financedAmount
, monthlyRate
, and numberOfPayments
publishers to finish doing their thing and when they're ALL done, THEN compute the monthly payment. Merge 3 seems to pick up changes in each of the publishers and for each change, it computes and spits out output I don't want.
Repo with problematic class and associated unit tests
I've tried mucking around with MergeMany
, Merge3
, .collect()
, but I can't get the syntax right. I've Googled the snot out of this and looked for examples in public GitHub repos, but I'm coming up with nothing that's germane to my situation. I'm trying to figure out what I'm mucking up and how to fix it.
These are my declarations for the other publishers upon which monthlyPayment
relies:
@Published var principalAmount: Double
@Published var mortgageTerm: MortgageTerm = .thirtyYear
@Published var downPaymentAmount: Double = 0.0
// monthlyRate replies upon annualRate, so I'm including annualRate above
internal lazy var monthlyRate: AnyPublisher<Double, Never> = {
annualRate
.print("monthlyRate", to: nil)
.map { rate in
rate / 12
}
.eraseToAnyPublisher()
}()
public lazy var annualRate: AnyPublisher<Double, Never> = {
$mortgageTerm
.print("annualRate", to: nil)
.map { value -> Double in
switch value {
case .tenYear:
return self.rates.tenYearFix
case .fifteenYear:
return self.rates.fifteenYearFix
case .twentyYear:
return self.rates.twentyYearFix
case .thirtyYear:
return self.rates.thirtyYearFix
}
}
.map { $0 * 0.01 }
.eraseToAnyPublisher()
}()
public lazy var financedAmount: AnyPublisher<Double, Never> = {
Publishers.CombineLatest($principalAmount, $downPaymentAmount)
.map { principal, downPayment in
principal - downPayment
}
.eraseToAnyPublisher()
}()
public lazy var numberOfPayments: AnyPublisher<Double, Never> = {
$mortgageTerm
.print("numberOfPayments: ", to: nil)
.map {
Double($0.rawValue * 12)
}
.eraseToAnyPublisher()
}()
I attempted to use Merge3
with .collect()
, but my unit test is timing out on it. Here's the updated monthlyPayment
declaration:
public lazy var monthlyPayment: AnyPublisher<Double, Never> = {
Publishers.Merge3(financedAmount, monthlyRate, numberOfPayments)
.collect()
.map { mergedArgs in
let numerator = mergedArgs[1] * pow((1 + mergedArgs[1]), mergedArgs[2])
let denominator = pow((1 + mergedArgs[1]), mergedArgs[2]) - 1
return mergedArgs[0] * (numerator / denominator)
}
.eraseToAnyPublisher()
}()
The test now fails with a timeout and the .sink
code is never called:
func testMonthlyPayment() {
// sut is initialized w/ principalAmount of $100,000 & downPaymentAmount of $20,000
let sut = calculator
let expectation = expectation(description: #function)
let expectedPayments = [339.62, 433.97, 542.46]
sut.monthlyPayment
.collect(3)
.sink { actualMonthlyPayment in
XCTAssertEqual(actualMonthlyPayment.map { $0.roundTo(places: 2) }, expectedPayments)
expectation.fulfill()
}
.store(in: &subscriptions)
// Initialized with 30 year fix with 20% down
// Change term to 20 years
sut.mortgageType = .twentyYear
// Change the financedAmount
sut.downPaymentAmount.value = 0.0
waitForExpectations(timeout: 5, handler: nil)
}
Upvotes: 5
Views: 698
Reputation: 32782
The problem is caused by the fact that numberOfPayments
and monthlyRate
publishers are co-dependent, and both follow the mortgageTerm
publisher. Thus, when $mortgageTerm
emits an event, you end up with two other independent events emitted by the follower publishers, and this breaks your flow.
This also indicates you're using too many publishers for things that can be easily solved with computed properties, but I assume you want to experiment with publishers, so, let't give it a go with this.
One solution is to use only one publisher for the two problematic pieces of information, a publisher that emits tuples, and which makes use of some helper functions that calculate the data to emit. This way, the two pieces of information that should be emitted at the same time are, well, emitted at the same time :).
func annualRate(mortgageTerm: MortgageTerm) -> Double {
switch mortgageTerm {
case .tenYear:
return rates.tenYearFix
case .fifteenYear:
return rates.fifteenYearFix
case .twentyYear:
return rates.twentyYearFix
case .thirtyYear:
return rates.thirtyYearFix
}
}
func monthlyRate(mortgageTerm: MortgageTerm) -> Double {
annualRate(mortgageTerm: mortgageTerm) / 12
}
func numberOfPayments(mortgageTerm: MortgageTerm) -> Double {
Double(mortgageTerm.rawValue * 12)
}
lazy var monthlyDetails: AnyPublisher<(monthlyRate: Double, numberOfPayments: Double), Never> = {
$mortgageTerm
.map { (monthlyRate: self.monthlyRate(mortgageTerm: $0), numberOfPayments: self.numberOfPayments(mortgageTerm: $0)) }
.eraseToAnyPublisher()
}()
With the above setup in place, you can use the combineLatest
that you attempted first:
func monthlyPayment(financedAmount: Double, monthlyRate: Double, numberOfPayments: Double) -> Double {
let numerator = monthlyRate * pow((1 + monthlyRate), Double(numberOfPayments))
let denominator = pow((1 + monthlyRate), Double(numberOfPayments)) - 1
return financedAmount * (numerator / denominator)
}
lazy var monthlyPayment: AnyPublisher<Double, Never> = {
financedAmount.combineLatest(monthlyDetails) { financedAmount, monthlyDetails in
let (monthlyRate, numberOfPayments) = monthlyDetails
return self.monthlyPayment(financedAmount: financedAmount,
monthlyRate: monthlyRate,
numberOfPayments: numberOfPayments)
}
.eraseToAnyPublisher()
}()
Functions are a powerful tool in Swift (and any other language), as clearly defined and specialized functions help with:
In your particular example, I'd go even one step further, and define this:
func monthlyPayment(principalAmount: Double, downPaymentAmount: Double, mortgageTerm: MortgageTerm) -> Double {
let financedAmount = principalAmount - downPaymentAmount
let monthlyRate = self.monthlyRate(mortgageTerm: mortgageTerm)
let numberOfPayments = self.numberOfPayments(mortgageTerm: mortgageTerm)
let numerator = monthlyRate * pow((1 + monthlyRate), Double(numberOfPayments))
let denominator = pow((1 + monthlyRate), Double(numberOfPayments)) - 1
return financedAmount * (numerator / denominator)
}
The above function clearly describes the problem domain of your screen, as its main feature is to compute a monthly payment based on three inputs. And with the function in place, you can resume the whole set of publishers, to only one:
lazy var monthlyPayment = $principalAmount
.combineLatest($downPaymentAmount, $mortgageTerm, self.monthlyPayment)
You get the same functionality, but with less amount of, and more testable, code.
Upvotes: 3