Adrian
Adrian

Reputation: 16715

Unexpected Combine Publisher Behavior

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:

  1. the numberOfPayments changes due to the change in the mortgage term (length)
  2. the monthlyRate due to a change in the mortgage term (length)

End Goal

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

What I've Tried

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.

Supporting Declarations

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()
}()

Update

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

Answers (1)

Cristik
Cristik

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:

  • code structure
  • redability
  • unit testing

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

Related Questions