Why you should care about types, and how this impacts automated testing

Salvatore Pelligra
Jun 13 · 7 min read
Image by Prettysleepy2 from Pixabay

My passion for programming started thanks to a dynamically-typed language (PHP), so I learned the hard way how much a well-typed program and a checker/compiler can help us to prevent mistakes.

I also worked a lot with automated testing. I learned this skill back in the day thanks to the awesome Ruby community, but this one is dynamically typed too.

In this article, we will use a real-world case that I’ve encountered while developing CRUI to show how types help us delivering better code and how they relate to tests.

As reference languages, we will use Javascript and Typescript, but this article applies to any dynamic and typed language.


No Types

In CRUI we have a function to bind a Stream and a particular element property. In it’s simplest form, it looks like this:

function h$b(tag, bind)

Honestly, I would not know what to do with this function.

Silly me, the problem is clearly not having documentation! Let’s add it.

/**
* Element with Bind.
* Create an DOM Node and bind properties to a StreamBox.
*
* @param tag A string with an HTML Tag like 'div'
* @param bind An object specifying which property to bind to which stream box.
* @returns A component
*/
function h$b(tag, bind)

Ok, now I’m ready to use it: I’ll use'input' for tag and for the second one I need an object with a StreamBox and… wait a minute, what are these properties the doc mentions?

After some digging we figure it out (again) and decide to fix the problem once and for all:

/**
* Element with Bind.
* Create an DOM Node and bind properties to a StreamBox.
*
* @param tag A string with an HTML Tag like 'div'
* @param bind An object specifying which property to bind to which stream box. We support 2 properties: `value` and `checked`.
* This parameter should look like: {value: StreamBox}
* @returns A component
*/
function h$b(tag, bind)

A valid example will be:

const stream = new StreamBox('')
const comp = h$b('input', {value: stream})

Now that we understand the function, we still need to verify that it does what it promises.


No Types — Testing

I consider testing to be a fundamental part of software developing. We have mainly two ways to test this function:

  • Unit tests are performed in isolation and testing just the unit at hand. Isolation often requires mocking dependencies.
  • Integration tests consider the whole functionality rather than a single function, keeping as many real dependencies as possible.

For a simple function like this, units tests should be enough.

So we start testing and cover all of the following cases:

  • Passing any properties other than value or checked will throw an Exception
  • Passing something else than input or other valid HTML Element tags that support value and checked will throw an Exception
  • Only input tag is allowed for checked property
  • Setting a value to a StreamBox bound to value will stringify it
  • Setting a value to a StreamBox bound to checked will make it a boolean
  • Changing a StreamBox value also changes Element value
  • Changing Element value also changes StreamBox value

Phew! We wrote quite some tests, but now we are fully confident that this function works and will keep working as our codebase evolves.

Let’s use it in our app. It’ll look something like this:

const stream = new StreamBox('')
hc('div', [
h$b('input', { valeu: stream })
])

We tested both hc and h$b extensively, so we are quite confident that this code will work. We run it through our favorite bundler and open the browser to enjoy our marvelous creation… a blank page.

OK, something went wrong. We open up the browser console and find an exception like:

Error: `valeu` property is not supported.

Ouch! A stupid typo.


No Types — Lessons Learned

  • A function signature usually tells us very little about how to use it.
  • Documentation is a necessity, but not always reliable; either it doesn’t have enough information or is outdated.
  • We need to write lots of tests and defensive code to ensure our function behaves properly and that it’s easy to figure out what went wrong and where.
  • Correctness does not compound: unit tests have very limited guarantees about correctness in dynamically and weakly typed languages.
  • To ensure correctness, we need to write lots of integration tests, which are often hard to maintain, slow to execute and to write.
  • Given that 100% integration tests coverage is usually not a thing, every new line we add in our code base can trigger a new failure that will only be caught at runtime. Personally, this is a nightmare.

When working with dynamically/weakly typed languages, the burden of ensuring the code is correct is almost completely on the developer. We must be much more careful and diligent to properly do our job.


Introducing Types

A typed version of the function above could be:

function h$b(tag: string, bind: Object): Function

This is valid in Typescript and a little better, but I would argue that it will not help us at all.

Adding random types will not solve anything. We need to properly understand the domain at hand and then express it through the type system as much as we can.

Let’s revisit the documentation we wrote for Javascript:

/**
* Element with Bind.
* Create an DOM Node and bind properties to a StreamBox.
*
* @param tag A string with an HTML Tag like 'div'
* @param bind An object specifying which property to bind to which stream box. We support 2 properties: `value` and `checked`.
* This parameter should look like: {value: StreamBox}
* @returns A component
*/

OK, we can codify this:

type Tag = string
type Bind = { value?: StreamBox, checked?: StreamBox }
type Component = () => Node
function h$b(tag: Tag, bind: Bind): Component

Now we’re talking! Zero documentation and we have already more information than before: Component is actually a function that receives nothing and returns a Node. (Please note that a CRUI Component is slightly more complex than this)

A big difference between types/code and documentation is that code cannot be out of sync.


Types — Testing

We have our function, but now we need to cover it again with tests.

Let’s revisit them.

Passing any property other than value or checked will throw an Exception.

Wait, we actually don’t have this problem anymore because we defined Bind; the type system will enforce that this rule is respected.

Ok, moving on to the next:

Passing something other than input or other valid HTML Element tags will throw an Exception

Um, we don’t cover this one, but I think types can help here:

type Tag = 'input'|'select'|'textarea'
function h$b(tag: Tag, bind: Bind): Component

Nice! If we try to use anything else than input , select or textarea the Typescript compiler will yell at us, forcing us to use a valid tag.

Next one:

Only input tag is allowed for checked property

Let’s think a little bit more here: we can use value for all Tags we just defined, but checked only with input . Interestingly enough, Typescript can help here too, thanks to function overloading:

type Tag = 'input'|'select'|'textarea'
type BindValue = { value: StreamBox }
type BindChecked = { checked: StreamBox }
function h$b(tag: Tag, bind: BindValue): Component
function h$b(tag: 'input', bind: BindChecked): Component

This definition can look intimidating, but it’s just encoding our acceptance criteria for this function: checked can be bound only to an input element, while value can be used for the defined elements. The code will be shared across the two definitions.

Another test skipped, next one, please!

Setting a value to a StreamBox bound to value will stringify it
Setting a value to a StreamBox bound to checked will make it a boolean

Said in other terms: value only works with strings and checked only with booleans.

We can definitely solve this by enhancing the StreamBox definition:

type BindValue = { value: StreamBox<string> }
type BindChecked = { checked: StreamBox<boolean> }

If you are not familiar with generics, this is just informing the compiler that StreamBox contains a value of type string for value and a boolean for checked.

We can add any other type between <> , that why it’s called generic.

Here are the last two acceptance criteria:

Changing a StreamBox value also changes Eelement value

Changing Element value also changes StreamBox value

No shortcuts here, we just need to write tests. This is the actual business logic for this particular function; the rest is just details.

In the end, we covered 5 acceptance criteria out of 7 by using the type system. That’s an incredible result, especially because our function logic and code is now free of all the defensive code and exceptions that we had to put in place in the Javascript version, making it simpler and less prone to error.


Lessons Learned

  • Just writing some types will not help. We need to write the right types.
  • It’s possible to encode quite some logic in the type system, which will avoid the necessity for runtime checks, simplify the logic, improve performance and preventing mistakes as we code, thanks to IDE support.
  • We need to write far fewer tests than before while having stronger guarantees of overall correctness.
  • Correctness composes: types can ensure that each function is used in the intended and correct way; therefore unit tests are a lot more relevant.
  • Integration tests are less relevant, but still important to cover cases for which types are not enough, eg: a function that expects elements in a certain order to properly do its job.
  • 100% test coverage is achievable through lots of unit tests and some integration tests. Personally, I would not consider this a requirement.

The most important bit here is that the compiler will now prevent us (and any user of our function) to make any stupid mistakes — and that will boost productivity.

Real case, better than tests

While developing CRUI, I actually had some bugs, due to how Streams works. My first instinct was to write a test for it, but I quickly realized that this would have not helped at all. Tests are useful for internal behaviors — but they’re quite ineffective against problems derived by receiving the wrong piece of information.

Better Programming

Advice for programmers.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade