DEV Community

Amanpreet Singh
Amanpreet Singh

Posted on • Originally published at blog.amanpreet.dev on

Test-Driven Development (TDD) with TypeScript for Beginners

TDD using TypeScript

Introduction to TDD

Before we deep dive into Test-Driven Development or TDD, let me ask a simple question to you.

Why developers do not write Tests often?

I being a software developer for the past 15 years have been asked/questioned this by/to my colleagues, juniors, and manual QA team members. Here are some of the common reasons.

  1. Time Constraints : Writing tests takes time and with developers under tight schedules usually skip tests to meet delivery timelines.

  2. Misconceptions that Tests aren't necessary : Some developers believe that if they write code carefully, it won't have bugs which leads to overconfidence and perceptions that tests are unnecessary.

  3. Lack of Awareness or Training : Not all developers are trained in testing or understanding the benefits of tests. Some might not be aware of tools, and frameworks used for specific languages or platforms.

  4. Short-Term vs Long-Term Perspective : Developers should consider tests as an investment that pays off in the long run by reducing bugs and improving code readability.

  5. Lack of Management Support : If management doesn't recognize or advocate for the importance of testing, developers aren't appreciated and thus it becomes a less likely routine among developers.

Test-Driven Development (TDD) is a software development methodology that emphasizes the importance of writing tests before writing the actual implementation code.

What is TDD?

Test-Driven Development or TDD is a software methodology that enables developers to design simple, clean, and tested code. It is a standard way of producing high-quality software which is incorporated with the best coding and design practices.

Here is a step-by-step overview of the TDD process:

  1. Write a Failing Test : Write a test for the feature that you are about to develop and that doesn't work initially.

  2. Write the Minimum Code to Pass the Test : Write the minimum amount of code needed to pass the test.

  3. Run the Test : Execute the test. If it fails, modify the code and retest until it passes.

  4. Refactor : Once the test passes, examine the code and see if it can be improved or optimized without changing its behavior. One of the most important steps is to ensure code quality and maintainability.

  5. Repeat : Go back to the first step and repeat the process for the next piece of functionality or feature you want to develop.

Advantages of TDD in Software Development

Below are the precise points highlighting the advantages of Test-Driven Development (TDD)

  • Improved Code Quality : TDD enforces writing tests, leading to more reliable and bug-free code.

  • Faster Debugging : By catching defects early in the development process, TDD reduces debugging time and minimizes the impact of errors.

  • Confident Refactoring : Developers can confidently refactor code since existing tests ensure that the intended functionality remains intact.

  • Continuous Integration : TDD complements CI/CD pipelines by integrating automated tests, ensuring that the new changes don't break existing functionalities.

  • Better Collaboration and Increased Productivity : Well-defined tests facilitate communication between developers and testers to understand the expected outcomes. TDD leads to better code design, saving time and effort during later stages of development.

How TDD works?

"Red-Green-Refactor" is the mantra that summarizes the TDD cycle:

red-green-refactor

  • Red : Writing a failing test means seeing a red indication from your test runner.

  • Green : Here, we write the minimum amount of code to pass the test thus getting a green indication in our test runner.

  • Refactor : Clean up the code, and make it more readable, and efficient without making the test fail. After refactoring the test should still pass.

Importance of Test Coverage

Since code is written around tests, there is a higher chance of more code covered by tests. This leads to fewer bugs and easier maintenance.

Remember it is not about 100% code coverage where our tests don't make sense or are not working as per expectations.

Getting Started with TDD and TypeScript

Ok, a lot about TDD in theory, now let's get deep knowledge by actually doing some code-level examples. We will be using TypeScript and Jest as our testing frameworks.

I will be using NodeJs v20.2 for this project, although it should work with almost all stable node versions. All the code will be available in the Github repo shared under the References section.

Setting up a TypeScript Project

To get started, open your terminal and create a new folder named tdd-for-beginners (you can choose a name of your choice too) and move into that directory. Use the following commands:

mkdir tdd-for-beginners && cd tdd-for-beginners

Enter fullscreen mode Exit fullscreen mode

Next, we will initialize our project using the following command:

npm init -y
# using -y flag tells npm init to automatically use the defaults.

Enter fullscreen mode Exit fullscreen mode

by running the above command a new file will be created in the repository, named package.json

Install TypeScript and ts-node

Now, let's configure the TypeScript compiler. Run the following command inside your project directory to install TypeScript.

npm i -D typescript ts-node
# This will add the package under devDependencies

Enter fullscreen mode Exit fullscreen mode

TypeScript is a typed superset of Javascript that compiles to plain Javascript. ts-node is a tool that allows you to run TypeScript code directly, without the compile step. Install them as development dependencies using the above command.

Create a tsconfig.json file:

TypeScript uses a file called tsconfig.json to configure the compiler options for a project. Let's create this file in the root of the project directory.

# Will create the tsconfig.json file with default options
npx tsc --init

Enter fullscreen mode Exit fullscreen mode

Configuring Testing Environment

We will be using Jest as the testing framework. Let's install the required dependencies by using the following command:

npm i -D jest ts-jest @types/jest

Enter fullscreen mode Exit fullscreen mode

Before we write our first test, we need to configure a few things. Let's do them below:

  1. To specify jest as our test runner, update the package.json file as follows

  2. Configure Jest: Create a jest.config.js file at the root of your project.

  3. TypeScript Configuration: Add the include and exclude properties after compilerOptions

Example 1: Writing Our First Test for adding two numbers

We will use TDD methodology to add two numbers and write tests for them. Now let's write our first test before we deep dive into more complex functionalities.

  1. Create a Typescript file for the function e.g addNumbers.ts:

  2. Create a test file e.g addNumbers.spec.ts

  3. Now run the tests by using the below command:

  4. Now let's make the test pass by adding the required functionality in the addNumbers.ts file. This follows our Green pattern.

Now there is not much to refactor here, so we will try to cover this concept in other complex examples.

Example 2: Writing a Palindrome Checker

Let's create a simple utility function to check if the string is palindrome or not. We will first create two files palindrome.ts and palindrome.spec.ts

// palindrome.spec.ts 
import { isPalindrome } from './palindrome';

test('should return true for a palindrome', () => {
  expect(isPalindrome('racecar')).toBe(true);
});

test('should return false for a non-palindrome', () => {
  expect(isPalindrome('hello')).toBe(false);
});


// palindrome.ts
export function isPalindrome(str: string) {

}

Enter fullscreen mode Exit fullscreen mode

Now when you run the command npm test you will see a failed test or something like this. Remember the RED pattern.

failed-test-case

Defining the Test Case

Now let's fix our test and for that, we should understand what the requirements are or in this case, what is a palindrome string.

  1. An inputted string reads the same forward and backward for e.g - "racecar"

  2. The input string which is not the same when read in reverse, is not a palindrome. e.g "hello"

  3. The function should ignore the case while determining whether the string is palindrome or not. e.g "Madam"

  4. The function should handle spaces and recognize the input string accordingly. e.g "A man, a plan, a canal, Panama"

  5. The function should consider special characters while determining palindrome strings. e.g "$#malyalam#$"

  6. Some of the edge cases to be considered: A single character or an empty string is also considered a palindrome. for e.g "a", ""

So in total, there are six test cases for which we need to write a code and make the test pass.

Implementing the Function

Based on the above requirements, let's work on the code and check which test cases pass and which fail.

export function isPalindrome(str: string) {
  const reversed = str.split("").reverse().join("");
  return str === reversed;
}

Enter fullscreen mode Exit fullscreen mode

Running the Test

Based on the test cases defined earlier, we have six test cases to check whether our code works as expected or not.

Now let's run the test with different input string values and see where our test fails.

test("should return true for a palindrome", () => {
  expect(isPalindrome("Madam")).toBe(true);
});

Enter fullscreen mode Exit fullscreen mode

The above test fails as it doesn't consider case-sensitive, let's fix that in our code.

export function isPalindrome(str: string) {
  str = str.toLowerCase(); 
  const reversed = str.split("").reverse().join("");
  return str === reversed;
}

Enter fullscreen mode Exit fullscreen mode

Now the changes made as per the above code make our first three test cases pass. You can check the image below.

test-pass

Now Let's work on the other test cases. Suppose you change the input string to "A man, a plan, a canal, Panama", which is a palindrome string, lets's check our tests whether it gets passed or failed.

test("should return true for a palindrome", () => {
  expect(isPalindrome("A man, a plan, a canal, Panama")).toBe(true);
});

Enter fullscreen mode Exit fullscreen mode

The above test result will fail and the reason for it is not considering spaces, commas i.e. non-word characters though the string actually is a palindrome string. We need to remove non-word characters from the string. Let's refactor the code to make it pass.

// Remove the non-word characters 
export function isPalindrome(str: string) {
  str = str.replace(/\W/g, "").toLowerCase();
  const reversed = str.split("").reverse().join("");
  return str === reversed;
}

Enter fullscreen mode Exit fullscreen mode

Now by making the above changes our test is passed and it looks something like this. Remember the Green pattern

all-tests-pass

Now out of six defined test cases, Four have been passed successfully. Let's work on the remaining two test cases.

// Pass a single character or an empty string
test("should return true for a palindrome", () => {
  expect(isPalindrome("")).toBe(true);
});

Enter fullscreen mode Exit fullscreen mode

The above code will work and make our test pass but what if we add some special characters in our input string e.g. "#$malayalam$#", this will also pass our test.

test("should return true for a palindrome", () => {
  expect(isPalindrome("$#malayalam#$")).toBe(true);
});

Enter fullscreen mode Exit fullscreen mode

Thus all are test cases defined as earlier passes. The final test file to check all the test cases defined earlier will look something like this.

// All the test cases defined
test("should return true for a palindrome", () => {
  expect(isPalindrome("racecar")).toBe(true);
  expect(isPalindrome("Madam")).toBe(true);
  expect(isPalindrome("A man, a plan, a canal, Panama!")).toBe(true);
  expect(isPalindrome("$#malayalam#$")).toBe(true);
});

test("should return false for a non-palindrome", () => {
  expect(isPalindrome("hello world")).toBe(false);
  expect(isPalindrome("TypeScript")).toBe(false);
});

test("should handle empty strings", () => {
  expect(isPalindrome("")).toBe(true);
});

Enter fullscreen mode Exit fullscreen mode

The above examples demonstrate how to use TDD to develop small functions with TypeScript. We can apply the same principles and practices to more complex projects, increasing our confidence in the code's correctness and maintainability.

đŸ’¡ Remember, the key is to write tests first and then implement the functionality, incrementally building up your codebase.

It's worth noting that TDD is predominantly associated with unit testing, but the methodology can also be applied (with modifications) to higher levels of testing, such as integration or end-to-end tests.

Conclusion

Despite its benefits, TDD is a challenging way of developing high-quality software, especially for developers new to practice. It requires discipline to always write tests first and to resist the urge to jump directly into coding solutions.

Using the TDD approach encourages developers to think about their code's requirements, design, and functionality from the start, ultimately leading to better software design and fewer defects.

I hope you have learned something new as I did. If so, kindly like and share the article and also follow me to read more exciting articles. You can check my social links here.

References

Below is the Github repo with all the code and configuration files.

https://github.com/amanpreet-dev/tdd-for-beginners

I have committed the code based on various reference points, you can check the CHANGELOG and Commits reference accordingly.

Top comments (0)