Skip to content

Test-Driven Development (TDD)

In previous discussions, we explored the concept of Test-Driven Development (TDD). Despite being a valuable modern development strategy, TDD can initially feel counterintuitive to many developers.

To clarify how TDD works in practice, let’s walk through a comprehensive example that will highlight its nuances and help you become comfortable applying this technique in your own projects.

We’ll implement a ROT13 cipher algorithm—a straightforward yet instructive example inspired by James Shore’s TDD demonstration.

But first, let’s briefly review the core principles of TDD and its workflow.

TDD is a software development approach that emphasizes writing tests before writing the code that needs to be tested. The primary goal of TDD is to ensure that the code meets its requirements and behaves as expected.

flowchart TD
  B[Decide on a feature] ==> C[Write the test] 
  C ==>|Test Fails| D[Write code to make it pass]
  D ==>|Test Passes| E[Refactor the code]
  E ==>|Test Passes| B
  style C fill:#f87171, stroke:#dc2626,color:#333
  linkStyle 1 stroke:#dc2626
  linkStyle 2,3 stroke:#16a34a
  style D fill:#4ade80, stroke:#16a34a,color:#333
  style E fill:#60a5fa, stroke:#2563eb,color:#333

The TDD cycle consists of three key steps:

  • Test Fails: Start by writing one test that defines the behavior of the next feature you plan to implement. Since the feature does not yet exist, the test will fail, producing a “red” error message.
  • Test Passes: Write the minimum amount of code required to make the test pass. At this stage, focus solely on functionality, not on code design or optimization.
  • 🔄 Refactor: Once the test passes, improve the code’s structure and readability without changing its behavior. The test should continue to pass after refactoring. If it fails, the refactoring introduced an issue that needs to be addressed.

One of the major benefits of TDD is that writing tests first forces you to think about your code’s interface and behavior before implementation. The “red” phase in the red-green-refactor cycle serves as an important design exercise.

By implementing a test for code that doesn’t yet exist, you must make crucial decisions about:

  • How the code will be invoked
  • What behaviors it will exhibit
  • How it will communicate results

This process makes you approach your code from the user’s perspective (the test being the first user). The result is often code with clearer, simpler interfaces that’s easier to use and understand.

Successfully employing TDD requires working in very small, focused increments—a practice we’ll emphasize in our example below.

The TDD workflow involves:

  1. Implementing a single, narrowly-scoped test
  2. Writing just enough code to make that test pass
  3. Repeating until the entire feature is complete

This often means breaking down seemingly simple problems into even smaller steps.

Importantly, TDD does not require implementing all tests upfront—in fact, this is considered an anti-pattern. Instead, with proper TDD, you focus on just one test at a time, gradually building functionality through this iterative process.

The ROT13 cipher is a simple letter substitution cipher that replaces a letter with the letter 13 places down the alphabet. For example, ‘A’ becomes ‘N’, ‘B’ becomes ‘O’, and so on. The ROT13 cipher is its own inverse, meaning that applying it twice returns the original text.

block-beta
columns 1
block:top
  A
  B  
  style B fill:#4ade80,color:#333;
  C  
  D  
  E  
  style E fill:#60a5fa,color:#333;
  F  
  G  
  H 
  style H fill:#fb923c,color:#333; 
  I  
  J  
  K  
  L  
  style L fill:#facc15,color:#333;
  M 
end
space
block:bottom
  N 
  O 
  style O fill:#4ade80,color:#333;
  P 
  Q 
  R 
  style R fill:#60a5fa,color:#333;
  S 
  T 
  U
  style U fill:#fb923c,color:#333; 
  V 
  W 
  X 
  Y 
  style Y fill:#facc15,color:#333;
  Z
end
block:hello1
  H1["H"]
  style H1 fill:#fb923c,color:#333; 
  E1["E"] 
  style E1 fill:#60a5fa,color:#333; 
  L1["L"]
  style L1 fill:#facc15,color:#333;
  L2["L"]
  style L2 fill:#facc15,color:#333;
  O1["O"]
  style O1 fill:#4ade80,color:#333;
end
space
block:hello2
  H2["U"]
  style H2 fill:#fb923c,color:#333; 
  E2["R"] 
  style E2 fill:#60a5fa,color:#333; 
  L3["Y"]  
  style L3 fill:#facc15,color:#333;
  L4["Y"]  
  style L4 fill:#facc15,color:#333;
  O2["B"]
  style O2 fill:#4ade80,color:#333;
end
A --> N 
B --> O 
C --> P 
D --> Q 
E --> R 
F --> S 
G -- "ROT13" --> T 
H --> U 
I --> V 
J --> W 
K --> X 
L --> Y 
M --> Z
N --> A 
O --> B 
P --> C 
Q --> D 
R --> E 
S --> F 
T -- "ROT13" --> G 
U --> H 
V --> I 
W --> J 
X --> K 
Y --> L 
Z --> M
H1 --> H2
E1 --> E2
L1 -- "ROT13" --> L3
L2 --> L4
O1 --> O2

For our implementation, we’ll follow these specific rules:

  • Uppercase letters: Will rotate to other uppercase letters (A → N, B → O, etc.)
  • Lowercase letters: Will rotate to other lowercase letters (a → n, b → o, etc.)
  • Non-alphabetic characters: Numbers, punctuation, and special characters will remain unchanged

This approach ensures the cipher maintains the original text’s capitalization pattern and only transforms alphabetic characters, preserving all other elements of the input text exactly as they appear.

One of the primary benefits of writing tests first is that it forces you to think about your code’s interface and behavior before implementation. When writing your first test, you need to decide how you’ll call your code and what behavior you expect.

For our ROT13 cipher, we’ll first determine the overall structure. Since we’re implementing a single transformation function, creating a full class would be overkill. Instead, we’ll create a module that exports a rot13() function.

When starting with TDD, it’s crucial to begin with the most fundamental behavior. Rather than immediately testing letter transformation, we’ll start even simpler: verifying that our function returns an empty string when given an empty string as input. This establishes our basic interface.

Here’s our first test:

rot13.test.js
import { test, expect } from "vitest";
import { rot13 } from "./rot13.js";
test("returns empty string for empty input", () => {
const result = rot13("");
expect(result).toBe("");
});

Before running this test, we should predict the outcome. Our hypothesis: “The test will fail because our rot13 module doesn’t exist yet.”

In this phase, our goal is to write just enough code to make our test pass. We don’t need to worry about elegance or optimization yet—that comes during refactoring.

To satisfy our test, we’ll create the basic interface and hardcode the return value:

rot13.js
export function rot13() {
return "";
}

Notice how small this step is—we’ve only established the most basic interface, without even adding parameters. This demonstrates the incremental nature of TDD.

While these tiny steps might seem too cautious, they allow us to move quickly and confidently. This first cycle likely took less than a minute, yet we already have a meaningful test and the foundation of our implementation.

The final phase gives us an opportunity to improve our code without changing its behavior. We can enhance readability, optimize performance, or rename tests for clarity.

In this simple first cycle, there’s not much refactoring needed. Our implementation is minimal, and our test is straightforward.

For our second TDD cycle, we’ll tackle a small piece of our ROT13 function’s core logic. When applying TDD, the key is to work in extremely small, manageable steps.

While we know our final implementation will need to loop through each character in the input string, that would be too large a step for a single TDD cycle. Even handling “any single character” would be overly ambitious at this stage.

Instead, let’s focus on a highly specific case: transforming a single lowercase letter forward by 13 positions without handling alphabet wrap-around. For this test cycle, we’ll verify that ‘a’ transforms to ‘n’.

rot13.test.js
test("transforms one lowercase letter without wrapping", () => {
const result = rot13("a");
expect(result).toBe("n");
});

Before running this test, our hypothesis is: “This test will fail because our current implementation always returns an empty string regardless of input.”

To make our test pass, we need to:

  1. Modify our function to accept input parameters
  2. Preserve our existing functionality (returning empty string for empty input)
  3. Add the transformation logic for our specific test case
rot13.js
export function rot13(input) {
if (input === "") {
return "";
}
const charCode = input.codePointAt(0);
return String.fromCodePoint(charCode + 13);
}

This implementation uses two built-in JavaScript String methods:

Our solution simply adds 13 to the character code of the first letter. We’ve written just enough code to make our specific test pass—no more, no less.

At this stage, our implementation is straightforward and clean. There’s little need for refactoring, so we can proceed to the next TDD cycle, where we’ll incrementally add more functionality.

Let’s continue our TDD process by adding more functionality to handle single letter inputs completely.

Our next step is to handle transforming letters that need to loop around the alphabet. Let’s test the scenario where ‘n’ should transform to ‘a’:

rot13.test.js
test("transforms one lowercase letter with warping", () => {
const result = rot13("n");
expect(result).toBe("a");
});

To implement this correctly, we need to determine if a letter is in the first half of the alphabet (where we add 13) or the second half (where we subtract 13 to achieve the same effect as looping):

rot13.js
export function rot13(input) {
if (input === "") {
return "";
}
const charCode = input.codePointAt(0);
if (isBetween(charCode, "a", "m")) {
return String.fromCodePoint(charCode + 13);
} else if (isBetween(charCode, "n", "z")) {
return String.fromCodePoint(charCode - 13);
}
}
function isBetween(charCode, firstLetter, lastLetter) {
return (
charCode >= charCodeFor(firstLetter) && charCode <= charCodeFor(lastLetter)
);
}
function charCodeFor(letter) {
return letter.codePointAt(0);
}

These utility functions make our code more readable and easier to maintain:

  • isBetween() checks if a character code falls within a specified range
  • charCodeFor() provides a cleaner way to access character codes

Handling Uppercase Letters Without Looping

Section titled “Handling Uppercase Letters Without Looping”

Now let’s address uppercase letters. Following our incremental approach, we’ll first handle uppercase letters without looping:

rot13.test.js
test("transforms one uppercase letter without warping", () => {
expect(rot13("A")).toBe("N");
});

To make this test pass, we expand our condition to check for both lowercase and uppercase letters in the first half of the alphabet:

rot13.js
if (isBetween(charCode, "a", "m")) {}
if (isBetween(charCode, "a", "m") || isBetween(charCode, "A", "M")) {
return String.fromCodePoint(charCode + 13);
} else if (isBetween(charCode, "n", "z")) {
return String.fromCodePoint(charCode - 13);
}

Finally, let’s handle uppercase letters that require looping:

rot13.test.js
test("transforms one uppercase letter with warping", () => {
expect(rot13("N")).toBe("A");
});

Similar to our previous modification, we expand the second condition to check for uppercase letters in the latter half of the alphabet:

rot13.js
if (isBetween(charCode, "a", "m") || isBetween(charCode, "A", "M")) {
return String.fromCodePoint(charCode + 13);
} else if (isBetween(charCode, "n", "z")) {
} else if (isBetween(charCode, "n", "z") || isBetween(charCode, "N", "Z")) {
return String.fromCodePoint(charCode - 13);
}

Our implementation now correctly handles all single letter transformations, both uppercase and lowercase, with proper alphabet wrapping. Each small, focused TDD cycle has incrementally built our functionality while maintaining test coverage.

Handling Boundary Cases: Non-Alphabetic Characters

Section titled “Handling Boundary Cases: Non-Alphabetic Characters”

Let’s continue our TDD process by implementing tests for non-alphabetic characters that should remain unchanged.

Rather than testing every possible non-alphabetic character, we’ll focus on boundary cases - specifically the characters immediately before and after our alphabet ranges:

// Finding boundary characters using character codes
String.fromCodePoint("a".codePointAt(0) - 1) // "`"
String.fromCodePoint("z".codePointAt(0) + 1) // "{"
String.fromCodePoint("A".codePointAt(0) - 1) // "@"
String.fromCodePoint("Z".codePointAt(0) + 1) // "["

Let’s implement our first test for the character before 'a':

rot13.test.js
test("doesn't transform '`' (first char before 'a')", () => {
expect(rot13("`")).toBe("`");
});

To make this test pass, we need to add an else clause to return non-letter characters unchanged:

rot13.js
if (isBetween(charCode, "a", "m") || isBetween(charCode, "A", "M")) {
return String.fromCodePoint(charCode + 13);
} else if (isBetween(charCode, "n", "z")) {
} else if (isBetween(charCode, "n", "z") || isBetween(charCode, "N", "Z")) {
return String.fromCodePoint(charCode - 13);
}
} else {
return String.fromCodePoint(charCode);
}

Now we can add tests for the other boundary cases:

rot13.test.js
test("doesn't transform '{' (first char after 'z')", () => {
expect(rot13("{")).toBe("{");
});
test("doesn't transform '@' (first char before 'A')", () => {
expect(rot13("@")).toBe("@");
});
test("doesn't transform '[' (first char after 'Z')", () => {
expect(rot13("[")).toBe("[");
});

These additional tests should pass without any further code changes.

Now that we’ve completely implemented the single-letter transformation behavior, let’s refactor our code with an eye toward future enhancements. Specifically, we’ll extract the character transformation logic into its own function to make it reusable when we implement multi-character string support:

rot13.js
export function rot13(input) {
if (input === "") {
return "";
}
const charCode = input.codePointAt(0);
return transformChar(charCode);
}
function transformChar(charCode) {
if (isBetween(charCode, "a", "m") || isBetween(charCode, "A", "M")) {
return String.fromCodePoint(charCode + 13);
} else if (isBetween(charCode, "n", "z") || isBetween(charCode, "N", "Z")) {
return String.fromCodePoint(charCode - 13);
} else {
return String.fromCodePoint(charCode);
}
}
function isBetween(charCode, firstLetter, lastLetter) {
const firstCharCode = charCodeFor(firstLetter);
const lastCharCode = charCodeFor(lastLetter);
return charCode >= firstCharCode && charCode <= lastCharCode;
}
function charCodeFor(letter) {
return letter.codePointAt(0);
}

After making these changes, we should run all our tests to verify that our refactoring hasn’t broken any functionality.

Let’s continue our TDD journey by adding support for multi-character strings. We’ll go through the complete TDD cycle for this enhancement.

Our rot13() function should process each character in a multi-character string the same way it handles individual characters. Let’s create a test that verifies this behavior:

rot13.test.js
test("transforms a multi-character string", () => {
expect(rot13("abc")).toBe("nop");
});

Before running this test, let’s form a hypothesis: “Our current implementation only transforms the first character of any input string. So for the input “abc”, we expect it to transform ‘a’ to ‘n’, but ignore ‘b’ and ‘c’.” Running the test confirms this limitation.

To handle multi-character strings, we need to process each character in the input and build the transformed result:

rot13.js
export function rot13(input) {
if (input === "") {
return "";
}
let result = "";
for (let i = 0; i < input.length; i++) {
const charCode = input.codePointAt(i);
result += transformChar(charCode);
}
return result;
}

This implementation loops through each character in the input string, transforms it using our previously defined transformChar() function, and concatenates the results. Running the tests now shows they all pass.

Now that we support multi-character strings, we can refactor our tests to be more comprehensive:

rot13.test.js
test("transforms all lowercase letters", () => {
expect(rot13("abcdefghijklmnopqrstuvwxyz")).toBe(
"nopqrstuvwxyzabcdefghijklm"
);
});
test("transforms all uppercase letters", () => {
expect(rot13("ABCDEFGHIJKLMNOPQRSTUVWXYZ")).toBe(
"NOPQRSTUVWXYZABCDEFGHIJKLM"
);
});
test("doesn't transform multiple symbols", () => {
expect(rot13("`{@[")).toBe("`{@[");
});

These new tests provide complete coverage:

  • The first test verifies all lowercase letters transform correctly
  • The second test confirms all uppercase letters transform properly
  • The third test ensures multiple non-alphabetic characters remain unchanged

While our existing single-character tests might seem redundant now, they’re valuable for debugging and regression testing. If we change our implementation in the future, these granular tests will help pinpoint exactly where issues occur, so we’ll keep them.

With these changes, our ROT13 implementation is now complete and thoroughly tested.

Final TDD Cycles: Error Handling and Edge Cases

Section titled “Final TDD Cycles: Error Handling and Edge Cases”

Let’s complete our TDD process by adding error handling and support for special input cases like emojis.

During the “red” phase of TDD, we make design decisions about our code’s interface—including how errors should be handled. For our ROT13 implementation, we’ll design the function to throw an Error when it receives invalid input.

Let’s start by handling the case when no parameter is passed to our function:

rot13.test.js
test("throws an error when no parameter is passed", () => {
expect(() => {
rot13();
}).toThrowError("Expected string parameter");
});

Remember that toThrowError() expects a function as its first argument.

To make this test pass, we add a simple parameter check at the beginning of our function:

rot13.js
export function rot13(input) {
if (input === undefined) {
throw new Error("Expected string parameter");
}
// ...
}

Now let’s handle the case when a non-string value is passed:

rot13.test.js
test("throws an error when non-string is passed", () => {
expect(() => {
rot13(123);
}).toThrowError("Expected string parameter");
});

We can extend our parameter validation to check for the correct type:

rot13.js
export function rot13(input) {
if (input === undefined || typeof input !== "string") {
throw new Error("Expected string parameter");
}
// ...
}

Finally, let’s add tests for various special input cases to ensure our ROT13 function handles them correctly:

rot13.test.js
test("doesn't transform numbers", () => {
expect(rot13("0123456789")).toBe("0123456789");
});
test("doesn't transform non-English letters", () => {
expect(rot13("ñåéîøüç")).toBe("ñåéîøüç");
});
test("handles emojis correctly", () => {
expect(rot13("🤓🤩")).toBe("🤓🤩");
});

Interestingly, the emojis are not handled properly by our current implementation. The codePointAt() method returns the code point of the first character, but emojis can be represented by multiple code points.

There are multiple ways to handle this, but the easiest solution is to change our for loop by a for...of loop, which correctly iterates over Unicode characters. This ensures proper handling of surrogate pairs so characters like emojis are treated as single units.

rot13.js
export function rot13(input) {
if (input === undefined || typeof input !== "string") {
throw new Error("Expected string parameter");
}
if (input === "") {
return "";
}
let result = "";
for (let i = 0; i < input.length; i++) {
for (let char of input) {
const charCode = input.codePointAt(i);
const charCode = charCodeFor(char);
result += transformChar(charCode);
}
return result;
}

The other tests should pass without any changes. We’re free to refactor further if we want, but our implementation is already clean and efficient.

While TDD adoption varies widely across companies, teams, and individual developers, many successful tech companies incorporate TDD practices, including:

  • Extreme Programming (XP) practitioners
  • Companies building mission-critical software where reliability is paramount
  • Teams maintaining large codebases over long periods
  • Some open-source projects with strong testing cultures

That said, TDD isn’t universally practiced, even among those who value testing. Many developers:

  • Write tests after implementation rather than before
  • Use a hybrid approach based on the feature complexity
  • Apply TDD selectively for critical or complex components
  • Find TDD too rigid for early-stage prototyping or UI development

The reality is that TDD exists on a spectrum in the industry. Some developers swear by strict TDD for everything, while others appreciate its principles but apply them flexibly. Kent Beck (who popularized XP and TDD) himself has noted that the goal is working, reliable software—not dogmatic adherence to any particular methodology.

For many teams, the core ideas of TDD—thinking through requirements before implementation, creating automated verification, and refactoring with confidence—provide value even when not followed to the letter.