Test-Driven Development

Test-Driven Development (TDD) is a software development process that relies on the repetition of a very short development cycle. First, the developer writes a test that defines a desired function or improvement. Then, they produce the minimum amount of code to pass that test and finally refactor the new code to acceptable standards. TDD ensures that your code remains clean, well-structured, and that it meets the required functionality right from the start.

In this lesson, we’ll explore how to implement TDD.

What is TDD?

TDD consists of three main steps, often referred to as the Red-Green-Refactor cycle:

  1. Red: Write a failing test case for a new feature or improvement.
  2. Green: Write the minimum amount of code necessary to make the test pass.
  3. Refactor: Improve the code structure and readability without changing its behavior.

“Red”, named after the red you would see in a failing test:

Red

“Green”, named after the green you would see in a passing test:

Green

This cycle ensures that your code is constantly being tested, making it more reliable and easier to maintain.

Writing Your First Test

Let’s start with a simple example. Suppose we want to write a function that checks if a number is even.

  1. Red: Write the failing test

Create a file named isEven.test.mjs in your tests directory:

javascript
	// tests/isEven.test.mjs
	import { describe, it, expect } from 'vitest';
	
	// We haven't written the isEven function yet
	import { isEven } from '../src/isEven';
	
	describe('isEven', () => {
	  it('should return true for even numbers', () => {
	    expect(isEven(2)).toBe(true);
	  });
	
	  it('should return false for odd numbers', () => {
	    expect(isEven(3)).toBe(false);
	  });
	});
  1. Green: Write the code to make the test pass

Create the isEven function in a new file named isEven.mjs in your src directory:

javascript
	// src/isEven.mjs
	export function isEven(number) {
	  return number % 2 === 0;
	}

Run your tests using Vitest:

sh
	vitest run

You should see the tests pass:

txt
	PASS  tests/isEven.test.mjs
	  isEven
	    ✓ should return true for even numbers (2 ms)
	    ✓ should return false for odd numbers
  1. Refactor: Improve your code

In this case, the isEven function is simple enough that no further refactoring is needed. However, in more complex scenarios, you would look to improve the code’s readability, performance, or maintainability.

Extending the Functionality

Let’s extend our function to handle non-integer inputs gracefully.

  1. Red: Write the failing test

Update your test file to include a new test case:

javascript
	// tests/isEven.test.mjs
	import { describe, it, expect } from 'vitest';
	import { isEven } from '../src/isEven';
	
	describe('isEven', () => {
	  it('should return true for even numbers', () => {
	    expect(isEven(2)).toBe(true);
	  });
	
	  it('should return false for odd numbers', () => {
	    expect(isEven(3)).toBe(false);
	  });
	
	  it('should return false for non-integer inputs', () => {
	    expect(isEven('2')).toBe(false);
	    expect(isEven(null)).toBe(false);
	    expect(isEven(undefined)).toBe(false);
	    expect(isEven(2.5)).toBe(false);
	  });
	});
  1. Green: Write the code to make the test pass

Update your isEven function:

javascript
	// src/isEven.mjs
	export function isEven(number) {
	  if (typeof number !== 'number' || !Number.isInteger(number)) {
	    return false;
	  }
	  return number % 2 === 0;
	}

Run your tests again:

sh
	npm run test

You should see the new test pass:

txt
	PASS  tests/isEven.test.mjs
	  isEven
	    ✓ should return true for even numbers (2 ms)
	    ✓ should return false for odd numbers
	    ✓ should return false for non-integer inputs
  1. Refactor: Improve your code

Our isEven function is now more robust, handling different input types. The function’s logic is clear and concise, so no further refactoring is necessary.

Benefits of TDD

  • Improved Code Quality: Writing tests first forces you to consider the requirements and design before implementation.
  • Reduced Bugs: Constant testing ensures that bugs are caught early, reducing the cost of fixing them.
  • Better Documentation: Tests serve as a form of documentation, explaining what the code is supposed to do.
  • Increased Confidence: Knowing that your code is well-tested gives you confidence to make changes and add new features.

Best Practices for Test-Driven Development (TDD)

Test-Driven Development (TDD) is a powerful methodology that can lead to cleaner, more reliable code. However, to get the most out of TDD, it’s important to follow best practices. This page outlines key principles and tips to help you effectively implement TDD in your projects.

1. Start Small and Simple

  • Write Small Tests: Begin with simple, small tests that cover basic functionality. This helps you build confidence in your code and ensures that each piece works correctly before moving on to more complex scenarios.
  • One Test at a Time: Focus on writing one test at a time. Ensure it fails before writing the minimum code required to pass the test. This keeps you focused and avoids overwhelming complexity.

2. Follow the Red-Green-Refactor Cycle

  • Red: Write a failing test that specifies a new feature or improvement.
  • Green: Write the minimum amount of code necessary to make the test pass.
  • Refactor: Improve the code’s structure and readability without changing its behavior.

This cycle helps maintain a disciplined approach to development and ensures that your code is constantly being tested.

3. Write Clear and Understandable Tests

  • Descriptive Test Names: Use descriptive names for your test cases. A good test name explains what the test is checking and why.
    javascript
    	it('should return true for even numbers', () => {
    	  expect(isEven(2)).toBe(true);
    	});
  • Single Assertion per Test: Ideally, each test should have one assertion. This makes it clear what is being tested and simplifies debugging when a test fails.
    javascript
    	it('should return false for odd numbers', () => {
    	  expect(isEven(3)).toBe(false);
    	});

4. Maintain Test Isolation

  • Independent Tests: Ensure that each test can run independently of others. Avoid shared states or dependencies between tests.
  • Mock External Dependencies: Use mocks or stubs for external dependencies to isolate the unit under test. This ensures that tests are reliable and focus on the specific functionality being tested.

5. Prioritize Readability and Maintainability

  • Refactor Regularly: Regularly refactor both your production code and test code. Clean, readable code is easier to maintain and less prone to bugs.
  • Keep Tests Up-to-Date: As you refactor or add new features, update your tests accordingly. Outdated tests can give false confidence and lead to problems down the line.

6. Use Test Coverage Wisely

  • Aim for High Coverage: Strive for high test coverage, but don’t obsess over 100%. Focus on covering critical paths and complex logic.
  • Measure Coverage: Use tools like Vitest’s built-in coverage reporting to identify untested parts of your codebase.
    sh
    	vitest --coverage

7. Practice Incremental Development

  • Iterate Frequently: Make small, incremental changes and run your tests frequently. This helps catch issues early and makes debugging easier.
  • Commit Often: Commit your code frequently with meaningful messages. This helps track progress and makes it easier to revert changes if necessary.

8. Embrace Continuous Integration

  • Automate Testing: Set up continuous integration (CI) to run your tests automatically on each commit. This ensures that your code remains stable and reduces the risk of integration issues.
  • Monitor Build Status: Regularly monitor the status of your CI builds and address any failures promptly.

9. Write Tests Before Fixing Bugs

  • Reproduce Bugs with Tests: When fixing bugs, start by writing a test that reproduces the issue. This ensures that the bug is fixed and prevents regressions in the future.
    javascript
    	it('should return false for non-integer inputs', () => {
    	  expect(isEven('2')).toBe(false);
    	  expect(isEven(null)).toBe(false);
    	});

10. Collaborate and Review

  • Code Reviews: Include test code in your code reviews. This helps catch potential issues early and promotes best practices across the team.
  • Pair Programming: Consider pair programming for complex or critical features. This can help improve the quality of both the code and the tests.

Conclusion

TDD is a powerful methodology that can significantly improve the quality and reliability of your code. By writing tests first and following the Red-Green-Refactor cycle, you ensure that your code meets its requirements and is easy to maintain.