Test-Driven Development (TDD) in Practice with Astro
Published
Test-driven development (TDD) means you write a test before you write the code. I built this website with TDD, using Vitest and Astro. This post explains how TDD works, how I set it up, and the real bugs it caught before they reached my visitors.
Key takeaways
- TDD is a simple loop: write a failing test, make it pass, then clean up the code.
- It works for small projects too. My personal site had more moving parts than I expected: two languages, a CMS, and pages generated for every tag and technology.
- The most useful tests checked the finished website, not only individual functions.
- A test that passes on its first run is a warning sign. Make sure you see it fail.
What is test-driven development?
TDD is a way of writing code in three short steps. People often call them red, green, refactor:
- Red: write a small test for something the code doesn't do yet. Run it and watch it fail.
- Green: write the simplest code that makes the test pass.
- Refactor: tidy up the code while the test keeps it safe.
Then you repeat the loop for the next small piece of behavior.
Watching the test fail first matters. It proves the test can actually catch a problem. A test that has never failed might not be testing anything.
Why use TDD on a small website?
It's tempting to skip tests on a personal site. It's small, and nobody gets woken up at night when it breaks.
But small sites still break in ways you won't notice by looking at them. Many of the bugs below were invisible in the browser. They only showed up in the HTML that search engines read. Tests find that kind of bug in seconds.
How I set up tests for an Astro site
I used three kinds of tests, from small to big.
1. Unit tests for small functions
All the logic lives in plain TypeScript functions, such as turning a technology name into a URL. Vitest runs these tests in under a second.
Writing the test first made me decide what should happen before writing any code. For example, what URL should C# get?
test('keeps meaning for symbol-heavy tech names', () => {
expect(slugify('C#')).toBe('csharp');
expect(slugify('C++')).toBe('cpp');
});
A simple version of this function would give C, C#, and C++ the same URL. Writing the expected result made me catch that early.
2. Component tests
Astro's Container API can turn a component into HTML inside a test. I used it to check the parts every page shares, like the header and the SEO tags.
3. Tests on the built website
This was the most valuable layer. The test script builds the whole site, then opens every HTML page and checks what search engines care about, for example:
test('has a canonical URL pointing to itself', () => {
expect(attr(html, /<link rel="canonical" href="([^"]+)"/)).toBe(expectedUrl);
});
One test like this runs on every page in both languages. That adds up to a few hundred checks, and they finish in seconds.
4 real bugs TDD caught
1. Every page redirected to a different address
Problem: my hosting (Cloudflare) served each page at an address ending in a slash, like /blog/my-post/, and sent visitors who typed /blog/my-post there with a redirect.
Why it matters: each page tells search engines its official address, called the canonical URL. Mine had no slash at the end, so every official address pointed to a redirect. Search engines don't like that.
Fix: I first wrote a test that failed on this. Then I changed how Astro saves pages, from my-post/index.html to my-post.html, so no redirect is needed.
2. The fix for bug 1 added ".html" to every address
Problem: after the change, pages saw their own address as /blog/my-post.html, and that ending leaked into the official addresses.
Fix: the tests on the built site failed on every page, so I noticed right away. I added a unit test for the function that builds addresses, then fixed it. The same test run also showed that the menu had stopped highlighting the current page, for the same reason.
3. The RSS feed had wrong links, and the test still passed
Problem: my first RSS test only checked that the feed contained the post address. The feed actually had the address with an extra slash, and the test passed anyway, because the correct address is part of the wrong one.
Fix: I made the test stricter, so it checks the whole link. It failed straight away, and the fix was a single setting in the RSS library. This is why seeing a test fail matters.
4. A blog title could break the page
Problem: each post includes hidden data for search engines inside a <script> tag. If a post title contained the text </script>, it would close that tag early and break the page.
Fix: a component test that feeds in exactly that text, then escaping the < character.
Tips if you want to try TDD
- Start small. Pick one function with clear inputs and outputs, and write its test first.
- Test what users and search engines receive, not only your functions.
- Write strict tests. A loose check can pass even when the output is wrong.
- Keep test data separate from real content. Some of my early tests depended on demo blog posts. When I deleted the demos, those tests had nothing left to check.
Conclusion
TDD didn't slow this project down. Every bug above was caught on my laptop, with a failing test that explained exactly what was wrong, instead of weeks later in a search engine report.
Read more posts about TDD, or see where I've used Vitest.