Using TDD to build a “click counter button” in TypeScript

Simon Lutterbie
6 min readFeb 13, 2021

--

This article chronicles a small part of my journey towards creating a full website that can be rendered in two parallel frameworks and design systems (React + Material UI; Angular + Clarity), plus a “vanilla” implementation with all components and business logic written “by hand”.

← Previous article: A new structure for my single page application

One of the classic early lessons when learning how to build components for your web app is the “click counter button”:

A button that tells you how many times you’ve clicked it.

While it’s a simple (and arguably useless) component, it’s an efficient way to practice some of the key elements of component-building, including storing values, handling events, and updating the display.

In my quest to build a parallel-framework site, then, it’s no surprise that I chose a click counter button to be my first interactive component. Before long, I’ll build the same component in React and Angular. But first, I wanted to build without a framework — using only “vanilla” TypeScript.

For the purposes of this article, I’ve focused on the component itself, and not the application into which it will be mounted. For current purposes, it’s sufficient to say that each component in my application handles a particular activity the user can engage in within my app. Hence, this component will be called clickToCountActivity.

I’ve also used this article to outline the test-driven development (TDD) approach I take my programming. I start by defining the component’s expected external behaviors, build a test for each expectation, and then build the component incrementally, to match each expectation. Tests were written in Jest.

Let’s begin!

I started by creating the directory, src/activity/click-to-count, and created inside of it two files: click-to-count.ts and click-to-count.test.ts.

In click-to-count.ts, I exported an empty clickToCountActivity function:

export function clickToCountActivity() {}

I started with a skeletal function so I could import it into click-to-count.test.ts, and build my first test.

The requirements for clickToCountActivity are fairly straightforward. It should:

  1. Be a button element.
  2. Start counting at 0, which should be displayed inside the button.
  3. Increment the count by 1 each time it’s clicked, which should be displayed inside the button.

Within click-to-count.test.ts, I set up my test suite and wrote my first test, confirming the component is a button:

import { clickToCountActivity } from './click-to-count';describe('clickToCountActivity', () => {
it('Should be a button', () => {
const clickToCount = clickToCountActivity();

expect(clickToCount.nodeName).toEqual('BUTTON');
});
});

Very basic. Note that HTMLElement.nodeName returns a CAPITALIZED string. I always forget this the first time I use it in a test, and this round was no exception.

As expected, my test failed. Good! Now that it would fail if the conditions weren’t met, I could build something that would pass the test!

To pass this test, clickToCountActivity needed to create and return a button element. That’s it. To satisfy TypeScript, I also needed to specify that the return type is a button element, as this was (apparently) not implied.

export function clickToCountActivity(): HTMLButtonElement {
const clickToCount = document.createElement('button');
return clickToCount;
}

Test ran… and passed! Success! Jest confirmed I had created a button!

The second expectation is that the component starts counting at 0, and this is indicated in the button text. The best way to test this is something of a judgment call:

  1. I could specify the full text of the button, such as “I’ve been clicked 0 times”; however, I don’t want my test to be overly strict on the appearance of my button as long as it displays the key information.
  2. I could ignore the text, and attach the count value to my button; however, I’d be implementing this purely for the tests, and it wouldn’t test external behavior.
  3. I could impose a condition on the text, such as “includes 0”; however, I could break this test by including “0” elsewhere in the button text.

I chose option 3 — specifying that the button text must include the count number. It’s not perfect, but provides a minimally restrictive test of external behavior. I could also have employed more advanced text-matching techniques (maybe a regular expression?), but that felt over-engineered for current purposes.

I wrote my second test as follows:

it('Should display an initial count of 0', () => {
const clickToCount = clickToCountActivity();

expect(clickToCount.textContent).toContain('0');
})

Note that the test is finding an item within a string so, to be precise, I specified that ‘0’ should also be a string. The test would also pass with 0 as a number.

I ran my second test and it failed! Great! I was ready to extend my component.

I took two “thinking ahead” shortcuts in this extension. Since I knew I was going to be incrementing the count, I created a variable, count, which I set to 0. And since I know I’m going to be updating the button’s text, I created a function to handle that and called it when creating the button, rather than simply hard-coding it:

export function clickToCountActivity(): HTMLButtonElement {
let count = 0;
const clickToCount = document.createElement('button');
updateButtonText();
function updateButtonText() {
clickToCount.textContent = `I've been clicked ${count} times`;
}
return clickToCount;
}

My test passed! Oh happy days! On to the final requirement.

Not only should the button display the correct initial count, it should display the updated count each time it is clicked. I could have written a separate test for this; however, I was testing essentially the same expected behavior as in my earlier test, so I decided to cheat a bit and combine the two. I extended my initial test, using theHTMLElement.click() method. To confirm that the component was updating upon every click as expected, I included multiple clicks in the test, and included a “two clicks” instance as well:

it('Should display a properly incrementing count', () => {
const clickToCount = clickToCountActivity();

expect(clickToCount.textContent).toContain('0');
clickToCount.click();
expect(clickToCount.textContent).toContain('1');
clickToCount.click();
expect(clickToCount.textContent).toContain('2');
clickToCount.click();
clickToCount.click();
expect(clickToCount.textContent).toContain('4');
});

Test ran, test failed. Ready to extend the component.

To make this button function as expected, I need to write a function that would be called every time the button is clicked and do the following:

  • Increment count by 1
  • Update the button’s text.

I also needed to attach the function as an EventListener on my button, listening for the click event. Once attached, the function should be called on every click. My updated component was as follows:

export function clickToCountActivity(): HTMLButtonElement {
let count = 0;
const clickToCount = document.createElement('button');
clickToCount.addEventListener('click', handleClick);
updateButtonText();
function handleClick() {
count += 1;
updateButtonText();
}
function updateButtonText() {
clickToCount.textContent = `I've been clicked ${count} times`;
}
return clickToCount;
}

Test ran, test passed! My component was complete!

The final step of creating this component was to fully implement it within my project. To do that, I needed to create a mountClickToCount(mountPoint) function, and export only that function from my component folder. I chose this approach to in order to clearly scope the “public API” by which other areas of my app could interact with my component. This called for two files, click-to-count.mount.ts and index.ts, outlined below:

click-to-count.mount.ts
=======================
import { MountActivityFn } from '../activities.types';
import { clickToCountActivity } from './click-to-count';
export const mountClickToCount: MountActivityFn =
function (mountPoint) {
const clickToCount = clickToCountActivity();
mountPoint.appendChild(clickToCount); return mountPoint;
};
index.ts
========
export { mountClickToCount } from './click-to-count.mount';

For more information on the structure of the above files and the thought process behind them, read my recent article on the structure of my single page application.

See the links below for all the changes made to my project within this piece of work, and also the full project as it currently stands.

--

--

Simon Lutterbie
Simon Lutterbie

Written by Simon Lutterbie

Senior Frontend Engineer committed to creating value and being a force-multiplier. Typescript, React, GraphQL, Cypress, and more. Also: PhD in Social Psychology

No responses yet