Skip to main content Accessibility Feedback

My favorite way to write code in 2026 (on becoming a TDD addict)

I’ve hated writing JavaScript tests for pretty my whole career.

That thing where you plan a component, write some code, iterate it a whole bunch until it works the way you want… and then write tests that validate that the thing you’ve already validated does in fact do the thing you already know it does? Yea, that sucks.

It’s also not what good testing looks like.

Today, I write my tests first, and then write my code. Today, I want to talk about why TDD (Test-Driven Development) is now my favorite way to write code. Let’s dig in!

Why testing sucks

There are two reasons why I personally have always hated testing…

  1. When the code is already done and working, the tests don’t feel like they’re actually doing anything useful.

    The code is already written. It’s working. Most of what you end up doing is nudging and tweaking tests until the pass. They add no value.

  2. We test the wrong things.

    There’s a tendency among (many, not all) developers to test how code achieves its tasks, rather than just the inputs/outputs. Testing how the code works (the implementation details) means that any time you refactor your code, you need to also refactor your tests.

    That kind of makes the tests useless.

Unlearning both of these bad habits has completely changed my relationship with testing.

Test-Driven Development (TDD)

With TDD, you write your tests first, expect them to fail (if they don’t, something is wrong), and then write your code until your tests pass.

Once you have passing tests, you can refactor your code to be more efficient, easier to read, and so on, and your tests will ensure you didn’t break anything along the way.

This is often referred to as Red, Green, Refactor.

I used to hate this, because I used to test implementation details, and how can you do that when you have no idea how your code is going to work!?

Learning to test inputs and outputs instead (and stop giving a damn about test coverage) made everything click.

An example

I use TDD a lot for writing web components.

Playwright is my testing tool of choice, because it runs in real browsers and can ingest HTML files. This makes it really optimal for testing UI components, but it can test pure JS functions, too.

I typically start with a blank HTML file

I’ll play around with what I want the starting web component HTML to look like. For example, here’s the HTML for a component that hides content in the HTML, and reveals it when one or more target checkboxes or radio buttons are selected.

<label>
	<input id="show-content">
	Show the content
</label>

<until-selected target="#show-content">
	I'm hidden until the checkbox is selected.
</until-selected>

I’ll usually create a second file that contains some variations with different options.

For example, for this component, I might fully hide the content by default, but have a version that uses visibility: hidden instead, so the element still occupies space in the UI.

<label>
	<input id="show-content">
	Show the content
</label>

<until-selected 
	target="#show-content"
	action="invisible"
>
	I'm hidden until the checkbox is selected.
</until-selected>

And I’ll create a variation that tests either error states or what happens before the JavaScript loads, or both.

<!-- Here, the triggering target element does not exist -->
<until-selected target="#nope">
	I'm hidden until the checkbox is selected.
</until-selected>

Next, I’ll stub out my tests

At this point, I still have no idea (or just a rough idea) how my script is going to work under-the-hood.

But I do know what the developer interactions and external behaviors should be.

I start by stubbing them out as comments/pseudo-code inside my test spec file.

test.describe('<until-selected>', () => {

	test('default component', async ({ page }) => {

		await page.goto('default.html');

		// 1. The content is hidden by default
		// 2. Check the checkbox
		// 3. The content is visible
		// 4. Uncheck the checkbox
		// 5. The content is hidden again

	});

	test('options and settings', async ({ page }) => {

		await page.goto('options.html');

		// 1. The content is hidden by default but still occupies space in the DOM
		// 2. Check the checkbox
		// 3. The content is visible
		// 4. Uncheck the checkbox
		// 5. The content is hidden again (but still occupies space in the DOM)

	});

	test('errors', async ({ page }) => {

		await page.goto('errors.html');

		// 1. The content is visible by default

	});

	test('no JS', async ({ page }) => {

		await page.goto('no-js.html');

		// 1. The content is visible by default

	});

});

Notice that nowhere in here did I say, “the content has the [hidden] attribute applied.”

That’s an implementation detail. We don’t care how the content is hidden. We only care that it is.

This means we can change how the component achieves its tasks later, and as long as the behaviors and developer options are the same, our tests will still pass.

Then, I write the actual test code

This part often takes longer than writing the actual component code, to be honest!

I take each comment in my test and translate it into a Playwright action or assertion.

Often, in the course of doing this, I’ll think of some additional option I want to provide or behavior I need to account for (such an accessibility consideration or edge case).

test('default component', async ({ page }) => {

	await page.goto('default.html');

	// Get the elements
	// page.locator() is like an async version of querySelectorAll()
	const toggle = page.locator('#show-content');
	const content = page.locator('until-selected');

	// 1. The content is hidden by default
	await expect(content).not.toBeVisible();

	// 2. Check the checkbox
	await toggle.check();

	// 3. The content is visible
	await expect(content).toBeVisible();

	// 4. Uncheck the checkbox
	await toggle.uncheck();

	// 5. The content is hidden again
	await expect(content).not.toBeVisible();

});

What about test coverage?

A lot of testing libraries will report out testing coverage, what percentage of your code is covered by the tests you wrote.

Playwright does not have that feature, and it’s been liberating!

Because it’s a vanity metric. You can have 100% code coverage, and not test things that actually matter. You could have tests that pass even when code breaks. Test coverage is irrelevant.

What does matter is that you’ve tested all of the different publicly-facing interactions between someone using your function or component and the code itself: the inputs/outputs.

And if you have a UI component that might be used with or inside another component, you should test those interactions as well.

Using the example above, it’s more important that I’ve tested every option/variant of the component (and every combination of options) than that I’ve tested 100% of the internal code.

Arguably, I achieve nearly 100% code coverage automatically by testing the interactions anyways.

But focusing on that code coverage number is part of leads to testing internals and implementation details. Sometimes, the only wait to hit that 100% number is by testing things that, frankly, don’t matter.

TDD is bliss

The reason I feel so enthusiastic about this now is that this approach has saved my ass so many times in the last year!

  • Refactors that break things I didn’t realize.
  • Stuff that I forgot to manually test while building.
  • Things that work in Chromium and Firefox but not Webkit.
  • Interaction bugs with other new components.

And writing code this way also lets me experiment with how I want to structure the HTML and interact with it before I write the actual code.

It is genuinely useful in helping me think through how the code should work.