By now, you should be familiar with the basics of Test-Driven Development (TDD) and its iterative Red-Green-Refactor cycle. This lesson focuses on honing your TDD mindset, a perspective that prioritizes writing tests before coding, which can dramatically improve code clarity, reliability, and maintainability.
We'll continue using TypeScript and Jest, tools that streamline our test-driven approach in TypeScript projects. Though we use Jest
for its popularity and integration ease, other alternatives such as Mocha
and Chai
exist for different preferences or project requirements.
Let's explore this mindset further with practical examples and visualize the flow of thinking in tests.
Let's begin by writing tests for a function named calculateTotal
, designed to compute the total price of items in a shopping cart. his is where you engage with the Red phase: Write a failing test.
Let's think about how to build a function that calculates lists of items. What should the interface be? How does the consumer of the code use it? These are the questions we think about first when we "think in tests". Here's one way we might think about it.
TypeScript1import { calculateTotal } from '../src/cart'; 2 3describe('calculateTotal function', () => { 4 it('should return 0 for an empty cart', () => { 5 const total = calculateTotal([]); 6 7 expect(total).toBe(0); 8 }); 9});
Explanation:
- We know we want a function called
calculateTotal()
so we'll write a test that uses it. - For now, we know that we want an empty array as an input to return 0, so we can write that test.
- The expectation is that these tests will initially fail, which is an integral part of the Red phase.
Running these tests confirms they fail, creating a clear path for subsequent development.
Now, let’s move to the Green phase where we implement the minimal code to pass these tests.
Implement the calculateTotal
function in cart.ts
:
TypeScript1export function calculateTotal(items: any[]): number { 2 return 0; 3}
Explanation:
- The
calculateTotal
function takes an array ofany
objects. We don't really know the shape of the data yet and that's OK! - Returning 0 is enough to get the test to pass.
By running the test suite again, we should see all tests passing, demonstrating that our function meets the required condition.
In the Refactor phase, we fine-tune the code for clarity and performance. It's always OK to do nothing in this step and that's what we'll do. Let's add another test.
Now is the time to think about what kind of data we want to pass to our calculateTotal
function. We consider that we'd like to pass the name
, price
, and quantity
as an object in the array. The total will be the product of price
* quantity
. Let's do that and see how it feels:
TypeScript1import { calculateTotal } from '../src/cart'; 2 3describe('calculateTotal function', () => { 4 it('should return 0 for an empty cart', () => { 5 const total = calculateTotal([]); 6 7 expect(total).toBe(0); 8 }); 9 10 it('should calculate the total for a single item', () => { 11 const total = calculateTotal([{ name: 'Apple', price: 0.5, quantity: 3 }]); 12 13 expect(total).toBe(1.5); 14 }); 15});
That feels pretty good; the interface seems clear. Let's see if we can get those tests passing.
Now we need to think about how to make these tests pass. What is the minimum necessary to get this to pass?
TypeScript1export function calculateTotal(items: any): number { 2 if (items.length > 0) { 3 return items[0].price * items[0].quantity; 4 } 5 6 return 0; 7}
When we run the tests, we should see that both tests pass! We're Green!
Now we ask ourselves: is there anything we can do to make this code better? One thing I might do is get rid of the repetition of the items[0]
in the calculateTotal
function. Another thing I might do is add a type to the items.
TypeScript1interface CartItem { 2 name: string, 3 price: number, 4 quantity: number, 5}; 6 7export function calculateTotal(items: CartItem[]): number { 8 if (items.length > 0) { 9 const item = items[0]; 10 return item.price * item.quantity; 11 } 12 13 return 0; 14}
When we run the tests again, we're still green. The code is more expressive now and has type checking so let's do the Red Green Refactor loop again!
The existing code works! But it won't be very useful to only use the first item. If we add one more test, we can generalize more.
TypeScript1import { calculateTotal } from '../src/cart'; 2 3describe('calculateTotal function', () => { 4 it('should return 0 for an empty cart', () => { 5 const total = calculateTotal([]); 6 7 expect(total).toBe(0); 8 }); 9 10 it('should calculate the total for a single item', () => { 11 const total = calculateTotal([{ name: 'Apple', price: 0.5, quantity: 3 }]); 12 13 expect(total).toBe(1.5); 14 }); 15 16 it('should calculate the total for multiple items', () => { 17 const items = [ 18 { name: 'Apple', price: 0.5, quantity: 3 }, 19 { name: 'Banana', price: 0.3, quantity: 2 } 20 ]; 21 expect(calculateTotal(items)).toBe(2.1); 22 }); 23});
As expected, this new test will fail and we can move to the Green step.
Let's take a stab at getting the test to pass:
TypeScript1interface CartItem { 2 name: string, 3 price: number, 4 quantity: number, 5}; 6 7export function calculateTotal(items: CartItem[]): number { 8 let total = 0; 9 10 for (let i = 0; i < items.length; i++) { 11 const item = items[i]; 12 total += item.price * item.quantity; 13 } 14 15 return total; 16}
This does the job and we're Green again. I can't help but feel like we could have written that code a bit better. Now that we have tests that cover everything we want this function to do, let's move to the Refactor step!
When we look at the calculateTotal
function, it is clear that this is an "aggregate function". It takes a list of items and reduces it to an aggregate value. Times like this call for reduce
!
TypeScript1interface CartItem { 2 name: string; 3 price: number; 4 quantity: number; 5} 6 7export function calculateTotal(items: CartItem[]): number { 8 return items.reduce((total, item) => total + item.price * item.quantity, 0); 9}
The reduce
function gets called with the running aggregate value (total
) and an item
for each item in the items
array. I like this code a lot better! Best of all, our tests tell us that we're still Green! We've successfully refactored the code.
Running the tests one more time shows we're still green!
Bash1 PASS test/cart.test.ts (6.229 s) 2 calculateTotal function 3 ✓ should return 0 for an empty cart (6 ms) 4 ✓ should calculate the total for a single item (3 ms) 5 ✓ should calculate the total for multiple items
Throughout this lesson, we delved into adopting a TDD mindset with a focus on writing tests before coding and following the Red-Green-Refactor cycle meticulously. Here are your key takeaways:
- Red: Start by writing a failing test. This ensures you have a clear goal and define the interface up-front.
- Green: Develop just enough code to pass the test, ensuring you fulfill the requirements.
- Refactor: Optimize your code for readability and maintainability, all while ensuring tests continue to pass.
These principles set a solid foundation for upcoming practice exercises, further cementing this mindset into your development workflow. Continue practicing the TDD cycle to foster robustness, code clarity, and reliability in your projects. Keep going, and embrace TDD as an essential part of your coding journey.