Skip to content

fluent (composable) assertions in Scala for describing domain validations

License

Notifications You must be signed in to change notification settings

sebaoliveri/fluent-assertions

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

76 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Fluent assertions

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.

Installation

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"

Usages

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