Consider this simple abstraction for
Money
that keeps track of amounts in various currencies. scala> import Money._ import Money._ // 1000 USD scala> val m = Money(1000, USD) m: laws.Money = (USD,1000) // add 248 AUD scala> val n = add(m, Money(248, AUD)) n: laws.Money = (AUD,248),(USD,1000) // add 230 USD more scala> val p = add(n, Money(230, USD)) p: laws.Money = (AUD,248),(USD,1230) // value of the money in base currency (USD) scala> p.toBaseCurrency res1: BigDecimal = 1418.48 // debit amount scala> val q = Money(-250, USD) q: laws.Money = (USD,-250) scala> val r = add(p, q) r: laws.Money = (AUD,248),(USD,980)
The valuation of
Money
is done in terms of its base currency which is usually USD
. One of the possible implementations of Money
is the following (some parts elided for future explanations) ..sealed trait Currency case object USD extends Currency case object AUD extends Currency case object JPY extends Currency case object INR extends Currency class Money private[laws] (val items: Map[Currency, BigDecimal]) { def toBaseCurrency: BigDecimal = items.foldLeft(BigDecimal(0)) { case (a, (ccy, amount)) => a + Money.exchangeRateWithUSD.get(ccy).getOrElse(BigDecimal(1)) * amount } override def toString = items.toList.mkString(",") } object Money { final val zeroMoney = new Money(Map.empty[Currency, BigDecimal]) def apply(amount: BigDecimal, ccy: Currency) = new Money(Map(ccy -> amount)) def add(m: Money, amount: BigDecimal, ccy: Currency) = ??? final val exchangeRateWithUSD: Map[Currency, BigDecimal] = Map(AUD -> 0.76, JPY -> 0.009, INR -> 0.016, USD -> 1.0) }
Needless to say we will have quite a number of unit tests that check for addition of
Money
, including the boundary cases of adding to zeroMoney
.It's not very hard to see that the type
Money
forms a Monoid
under the add
operation. Or to speak a bit loosely we can say that Money
is a Monoid
under the add
operation.A
Monoid
has laws that every instance needs to honor - associativity, left identity and right identity. And when your model element needs to honor the laws of algebra, it's always recommended to include the verification of the laws as part of your test suite. Besides validating the sanity of your abstractions, one side-effect of verifying laws is that you can get rid of many of your explicitly written unit tests for the operation that forms the Monoid
. They will be automatically verified when verifying the laws of Monoid[Money]
.Here's how we define
Monoid[Money]
using Cats ..val MoneyAddMonoid: Monoid[Money] = new Monoid[Money] { def combine(m: Money, n: Money): Money = add(m, n) def empty: Money = zeroMoney }
and the implementation of the previously elided add operation on
Money
using Monoid
on Map
..object Money { //.. def add(m: Money, amount: BigDecimal, ccy: Currency) = new Money(m.items |+| Map(ccy -> amount)) //.. }
Now we can verify the laws of
Monoid[Money]
using specs2 and ScalaCheck and the helper classes that Cats offers ..import cats._ import kernel.laws.GroupLaws import org.scalacheck.{ Arbitrary, Gen } import Arbitrary.arbitrary class MoneySpec extends CatsSpec { def is = s2""" This is a specification for validating laws of Money (Money) should form a monoid under addition $e1 """ implicit lazy val arbCurrency: Arbitrary[Currency] = Arbitrary { Gen.oneOf(AUD, USD, INR, JPY) } implicit def moneyArbitrary: Arbitrary[Money] = Arbitrary { for { i <- Arbitrary.arbitrary[Map[Currency, BigDecimal]] } yield new Money(i) } def e1 = checkAll("Money", GroupLaws[Money].monoid(Money.MoneyAddMonoid)) }
and running the test suite will verify the Monoid laws for
Monoid[Money]
..
[info] This is a specification for validating laws of Money
[info]
[info] (Money) should
[info] form a monoid under addition monoid laws must hold for Money
[info] + monoid.associativity
[info] + monoid.combineAll
[info] + monoid.combineAll(Nil) == id
[info] + monoid.combineAllOption
[info] + monoid.combineN(a, 0) == id
[info] + monoid.combineN(a, 1) == a
[info] + monoid.combineN(a, 2) == a |+| a
[info] + monoid.isEmpty
[info] + monoid.leftIdentity
[info] + monoid.rightIdentity
[info] + monoid.serializable
In summary ..
- strive to find abstractions in your domain model that are constrained by algebraic laws
- check all laws as part of your test suite
- you will find that you can get rid of quite a few explicitly written unit tests just by checking the laws of your abstraction
- and of course use property based testing for unit tests
No comments:
Post a Comment