Type Driven Development: Property Tests

Written by

in

Part 11 – Property Tests

This is a series of posts I’m writing about using types as another tool in software development, Continuous Delivery, & keeping LLM’s honest. They’re also a design & refactoring tool, a communication tool, and reduce how many tests you have to write.

Parts
  1. Part 1 – Branded Types
  2. Part 2 – Product Types
  3. Part 3 – Union & Discriminated Unions
  4. Part 4 – Non-Empty Collections
  5. Part 5 – Indexed Types
  6. Part 6 – unknown vs any
  7. Part 7 – Result
  8. Part 8 – Schema
  9. Part 9 – Total Function
  10. Part 10 – Errors as Values
  11. Part 11 – Property Tests

We’ve got more than enough type knowledge to build total functions. Type safety is a spectrum; is there a way to determine how total our functions are? A few. One particular way is to utilize Property Tests. You write a single unit test, it generates 100 random inputs. If one of those inputs fails, it’ll give you the random seed so you can test the exact sequence of inputs yourself (e.g. fast-check, jsverify):

type FormatName = (name:string) => stringCode language: JavaScript (javascript)

How could formatting a string fail? Let’s write 1 property test w/100 random strings to see:

forAll(string(), name => {
  expect(() => formatName(name)).not.toThrow()
})Code language: JavaScript (javascript)

It failed; logs show an empty string ” and a blank string ‘ ‘ made it fail. We can either fix this code:

name.trim()[0].toUpperCase() + name.trim().slice(1)Code language: CSS (css)

… or just fix the types:

type FormatName = (name:NonEmptyNorBlankString) => stringCode language: JavaScript (javascript)

Discriminated Unions + TypeState can help make Impossible Situations Impossible, but Property Tests beyond just the compiler can help prove it:

type State
  = { tag: "Loading" }
  | { tag: "Success"; data: Data }
  | { tag: "Failure"; error: string }Code language: JavaScript (javascript)

The DU + compiler prevents this impossible state:

{ loading: true, data: user, error: "bad" }Code language: CSS (css)

But not that your React useReducer goes from “Loading” to “Success” when you get data. Here be dragons and race conditions 😄!

Valid transitions:

Loading -> Success
Loading -> Error
Loading -> Error -> Retry -> Success
Loading -> Cancel -> Error

We can then generate random sequences like the above with just 1 test to ensure our state transition function’s like we’d expect:

test("random action sequences result in valid transitions", () => {
  // generate a random action sequence
  forAll(randomActionSequence(), sequence => {
    // loop through that sequence, then transition to each state
    forEach(sequence, ([action, previous]) => {
      // get the next state
      const next = reducer(previous, action)
      // see if you're actually allowed to transition to that state
      expect(
        isAllowedTransition(previous, action, next)
      ).toBe(true)
    })
  })Code language: PHP (php)

Again you’re left with a choice if it breaks; do you improve the code, or narrow your type to prevent invalid transitions? Up to you!

Types can prevent bad values from ever existing. You/your team will reach a point where your types may hurt your brain, instead relying on code to preserve the truth types cannot. Property Tests can help ensure the code does that.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *