Rewriting My App from Scratch with TDD and AI

Brian Kotos

July 23, 2025

What began as a small personal project turned into one of the more insightful experiments I've done around TDD and AI.

I built a simple web app—Scoreboard—to track scores when my friends and I play Cornhole. The app itself is straightforward, but it turned out to be a great opportunity to explore how test-driven development can work in tandem with modern AI tooling to make frontend codebases more flexible and maintainable.

The original version was written using TypeScript and the native DOM API, built using estrella (a lightweight build tool powered by esbuild). No frontend framework—just direct DOM manipulation, wired up test-first using Cypress and a red/green/refactor loop. It was lightweight, fast to build, and did exactly what I needed.

TDD let me work incrementally and verify even nuanced behaviors from the start. For example, I had this test early on to validate undo history with time-sensitive state changes:

it('should revert team 1 back to 2 if I press their add button 2 times, wait 3 seconds, press their add button 2 more times, and then press "undo"', () => {
// arrange
clickAddButton('Team 1')
clickAddButton('Team 1')
cy.wait(3000)
clickAddButton('Team 1')
clickAddButton('Team 1')
// act
cy.get('button').contains('Undo').click()
// assert
cy.get('#team1-score').should('contain', '2')
})

One of the things I love about Cypress specifically is that my tests aren't tightly coupled to any frontend framework. They operate purely through browser-level interactions—clicks, keystrokes, etc—which are much closer to how a real user interacts with the app. That means the same test suite can remain useful and accurate even if I change the implementation details, or rewrite the app in a different framework entirely.

Later, once the app was fully working, I tried something unusual: I deleted all the source code, leaving only the tests behind—and asked Cursor to rebuild the entire app from scratch.

You can see the full rewrite here: PR #1 Vanilla TypeScript to React. Functionally, the regenerated app was spot-on—it behaved just like the original. There were a few visual quirks, though—mostly spacing and hover behavior issues—that hadn't been captured in my original test suite. So I added new failing tests for those details and re-ran the process. Cursor adjusted accordingly, and the app came out looking just like the original as well.

For example, in the original version, when editing a team name, the input had a specific class applied to match styling expectations. I hadn't thought to test that detail, and the AI-generated version left it out. Once I noticed the regression, I added this Cypress test:

it('should have the subtitle class on the name text box when you click edit', () => {
// act
clickToChangeTeamName('Team 1')
// assert
cy.get('input[aria-label="Change team name"]').should('have.attr', 'class', 'subtitle mb-0 p-0')
})

With that in place, I re-ran the process and had AI make the test pass. This pattern—tightening tests in response to missed expectations—became a recurring theme.

That experiment worked so well, I decided to go one step further—not to replace the React version, but to add a second implementation in Vue.js, right alongside it. Same test suite, same functionality. I knocked that out in a single night. You can see that here: PR #2 Vue Implementation.

You can try both versions out for yourself and see how closely they match in behavior and appearance—despite being implemented in two different frameworks, they're both driven by the exact same test suite:

All of this pointed me toward a powerful conclusion: 🔥 TDD + AI unlocks disposable frontends.

On teams I've worked with, upgrading frontend frameworks is often a painful, time-consuming process. You're not just updating dependencies—you're battling years of historical, undocumented decisions, component sprawl, and duct-taped patterns. But if your app is built test-first, you don't have to do that. You can just delete the code and regenerate it from scratch using a newer framework, cleaner architecture, or more modern patterns. Your test suite becomes your real product. The implementation becomes replaceable.

It's also a great form of feedback. Any time the regenerated app didn't behave exactly like the original, it was because I hadn't written a test for that behavior. In this way, AI acted like a kind of mutation testing tool—subtly changing behavior in ways that surfaced the limits of my coverage. That feedback loop helped me tighten the spec and make expectations more explicit.

I plan to use this approach again in future projects. It's a compelling model for frontend longevity—and a strong case for writing small, focused tests that describe what your app should do, not how it does it. If you ever find yourself disillusioned with your current framework, you don't need a migration plan. You just need good tests and a clean slate.