A long long time ago I was taught real devs do not use IFs. However, when writing a condition using objects of different domains there's no way to avoid IFs. In OO preconditions are used when creating new instances to make sure objects are valid since momento zero, as well as describing preconditions when evaluation an object method to make sure invariants are preserved.
Large IF-ELSE lines of codes depend on the developer to maintain and are really hard to reason about. Let's delegate that task to the objects of fluent-assertions and lighten the work of the developer. At the end software models must be legible, representative of what they are modelling.
fluent-assertions is the result of materializing my motivation to propose a validation model to reify assertions as first class objects.
This lib supports Scala 2.13 Add in your build.sbt the following lines:
resolvers += Resolver.bintrayRepo("fluent-assertions", "releases")
libraryDependencies += "nulluncertainty" %% "fluent-assertions" % "2.0.1"
A user might sign up using its email or its mobile as part of its credentials:
case class UserRegistrationForm(maybeEmail: Option[String], maybePhoneNumber: Option[String], password: String)
Now, let's write an assertion to describe the required preconditions to Sign Up a user:
import org.nulluncertainty.assertion.AssertionBuilder._
val eitherAnEmailOrAPhoneMustBeSpecifiedAlongWithPassword =
assertThat({userRegistrationForm:UserRegistrationForm => userRegistrationForm.maybeEmail})
.isDefined
.isEmail
.orThat({userRegistrationForm:UserRegistrationForm => userRegistrationForm.maybePhoneNumber})
.isDefined
.isNumber
.isShorterThan(20)
.otherwise("Any of the email or the phone number must be specified")
.and(
assertThat({userRegistrationForm:UserRegistrationForm => userRegistrationForm.password})
.isNotBlank
.isLongerThan(5)
.isShorterThanOrEqualTo(15)
.otherwise("The password must be longer than 5 and shorter than 15"))
eitherAnEmailOrAPhoneMustBeSpecifiedAlongWithPassword is an Assertion instance. Underlying it is a composition of multiple assertions.
This assertion could be written more expressive if we parametrized T:
import org.nulluncertainty.assertion.AssertionBuilder._
val eitherAnEmailOrAPhoneMustBeSpecifiedAlongWithPassword =
assertThat[UserRegistrationForm](_.maybeEmail)
.isDefined
.isEmail
.orThat(_.maybePhoneNumber)
.isDefined
.isNumber
.isShorterThan(20)
.otherwise("Any of the email or the phone number must be specified")
.and(
assertThat[UserRegistrationForm](_.password)
.isNotBlank
.isLongerThan(5)
.isShorterThanOrEqualTo(15)
.otherwise("The password must be longer than 5 and shorter than 15"))
Given:
val userRegistrationForm = UserRegistrationForm(Some("[email protected]"), None, "1a2b3c$")
eitherAnEmailOrAPhoneMustBeSpecifiedAlongWithPassword requires a context for evalution. The context is an instance of UserRegistrationForm
eitherAnEmailOrAPhoneMustBeSpecifiedAlongWithPassword
.evaluate(userRegistrationForm)
.matches {
case AssertionSuccessfulResult(userRegistrationForm) => // keep going
case AssertionFailureResult(errors) => // maybe return BadRequest
}
AssertionFailureResult(errors) reifies the concept of a failure, and groups all the errors cause by unsatisfied assertions.
We can also select another strategy and raise an exception of type AssertionFailureException which also collects all the error messages of the failed assertions after evaluation:
eitherAnEmailOrAPhoneMustBeSpecifiedAlongWithPassword
.evaluate(userRegistrationForm)
.signalIfFailed()
If we need a custom exception:
eitherAnEmailOrAPhoneMustBeSpecifiedAlongWithPassword
.evaluate(userRegistrationForm)
.signalIfFailed(errors => new DomainValidationException(errors.mkString(", ")))
If you need a type Try:
eitherAnEmailOrAPhoneMustBeSpecifiedAlongWithPassword
.evaluate(userRegistrationForm)
.toTry()
.map(context => /* keep going */)
.recover { case AssertionFailureException(errors) => /* keep going */ }
or if you need a type Either:
eitherAnEmailOrAPhoneMustBeSpecifiedAlongWithPassword
.evaluate(userRegistrationForm)
.toEither()
.map(context => /* keep going */)
.recover { case AssertionFailureException(errors) => /* keep going */ }
or if you want to fold over an assertion result to return another type:
eitherAnEmailOrAPhoneMustBeSpecifiedAlongWithPassword
.evaluate(userRegistrationForm)
.fold(errorsHandlingFunction, successFunction)
You can also use an assertion for testing.
eitherAnEmailOrAPhoneMustBeSpecifiedAlongWithPassword
.evaluate(userRegistrationForm)
.expectsToBeTrue()
So far we saw assertions evaluated passing in a context. But we can also write assertions using values instead of functions. In this case we assert UserRegistrationForm instantiation to be valid, otherwise raise an exception. Take a look at the evaluate method. No context is passed in:
import org.nulluncertainty.assertion.AssertionBuilder._
case class UserRegistrationForm(maybeEmail: Option[String], maybePhoneNumber: Option[String], password: String) {
assertThat(maybeEmail)
.isDefined
.isEmail
.orThat(maybePhoneNumber)
.isDefined
.isNumber
.isShorterThan(20)
.otherwise("Any of the email or the phone number must be specified")
.and(
assertThat(password)
.isNotBlank
.isLongerThan(5)
.isShorterThanOrEqualTo(15)
.otherwise("The password must be longer than 5 and shorter than 15"))
.evaluate()
.signalIfFailed()
}
So far we saw the example of UserRegistrationForm assertion. But we can build composable assertions using any other types number string date collection option boolean
Assertions be be composed with operators:
ifTrue : When fails returns only anAssertion failure message. When success, evaluates anotherAssertion and returns its result.
anAssertion.ifTrue(anotherAssertion).evaluate(context)
and:
anAssertion.and(anotherAssertion).evaluate(context)
or:
anAssertion.or(anotherAssertion).evaluate(context)
ifFalse:
anAssertion.ifFalse(anotherAssertion).evaluate(context)
ifTrue ifFalse
anAssertion.ifTrue(anotherAssertion).ifFalse(yetAnotherAssertion).evaluate(context)
thenElse (takes a boolean expression as predicate)
import org.nulluncertainty.extension._
"jony".startsWithExp("j").thenElse(anAssertion, anotherAssertion).evaluate(context)
Assertions can also be composable by using Map and FlatMap:
import org.nulluncertainty.extension.QuantifiableExt._
assertThat({customersByAge: Map[Int,String] => customersByAge}).containsNoDuplicates.otherwise("repeated customers not allowed")
.map(_.keys)
.flatMap(ages => assertThat(ages).forAll(age => age.isGreaterThanExp(18)).otherwise("all customers must be 18 years old or greater"))
.evaluate(Map(
38 -> "Sebastian",
40 -> "Juan"
)).matches {
case AssertionSuccessfulResult(_) =>
case AssertionFailureResult(errors) =>
}