According to Aristotle, Technē is a type of wisdom which means craftsmanship or art. It's related to principles or rational methods involved in producing an object or accomplishing an end.
In the Software development field, it means all the things related to design principles and patterns, testing strategies and x-Driven Design.
In this article, I will share practically the foundations of a proven method of building efficient software. Let's start with TDD!
Test-Driven Development
Let’s talk about the philosophy of TDD — Write a test, get it to pass, and clean up the design. That’s all there is to TDD. But these three simple steps have way more sophisticated meanings to understand.
The TDD will help you incrementally design a reasonably complex algorithm.
We want to add a feature to totalize the price amount in the entire shopping cart. The shopping cart has items, quantities and discounts. So how can we test-drive this feature?
You will see that in this article with the TDD approach !
Begin with Tests List
Each test you write in TDD and get to pass represents a new, working piece of behaviour that you add to the system. While you don't determine all the tests up front, you'll probably have an initial set of thoughts about what you need to tackle.
This list can contain tests names or code cleanup reminders you need to do. You can type the test list at the bottom of your test file as comments — make sure you delete them before commit. You can make it as brief or lengthy as you like.
For our practical exercise, here is the test list of possible scenarios
/*
TESTS LIST
----------
- Total should be 0 when the basket is empty
- Total should be a unit price when it's a single item in basket
- Total should be unit price * quantity * discount of single item
- Total should be total of price * quantity * discount for many items
- Exceptional case: the quantity is O
- (quantity 0 doesn't makes sense in a shopping cart which have an item))
*/
Add them to the list as you test-drive and think of new test cases. Add a reminder to the list as you add code you know will have to be cleaned up. Then, as you complete a test or other task, cross it off this list. It's that simple.
You might think of the test list as a piece of the initial design. It can help you clarify what you think you need to build. Furthermore, it can also help trigger you to think about other things you have to do.
Managing test lists can be particularly useful when you're learning TDD.
I recommend you try it 😉
As you are learning TDD, seeking feedback can be helpful because seeing an error as soon as you write the code that generates it can make it easier to resolve. This approach is maintaining Uncle Bob’s Three Rules of TDD :
- Write production code only to make a failing test pass.
- Write no more of a unit test than sufficient to fail. Compilation failures are failures.
- Write only the production code that needs to pass the one failing test.
In TDD, you practice safe coding by testing early and often, so you usually have but one reason to fail.
Here is our first failing test we wrote to test the easier scenario in our test list
describe("Shopping cart total", () => {
it("return 0 when the cart is empty", () => {
const shoppingCart: {unitPrice: number; quantity: number; discount: number}[] = [];
const shopping = new Shopping();
const total = shopping.totalize(shoppingCart);
expect(total).toBe(0.0);
});
});
// ❌ At this stage our test fail
Now we can write a production code to make our code fail
export default class Shopping {
public totalize(cart: {unitPrice: number; quantity: number; discount: number}): number {
throw new Error("This is the first production code");
}
}
We can make in our production code, the minimum code required to make our failing test pass
export default class Shopping {
public totalize(cart: {unitPrice: number; quantity: number; discount: number}): number {
return 0.0;
}
}
// ✅ At this stage our test pass
Now that we have a failing test pass to green, we can now look for some refactoring in our current solution, we can for example introduce a Type Cart
in our test and production code
type Cart = {
unitPrice: number;
quantity: number;
discount: number;
};
Our production code, will look like this
public totalize(cart: Cart[]): number {
// ..
}
Our test code after the refactoring
it("return 0 when the cart is empty", () => {
const shoppingCart: Cart[] = [];
// ...
});
Since the test passed, it might be a good time to make a local commit. You should commit every time the code is green (in other words, when all tests pass). Doing so will ensure that if you get in trouble later, you can quickly revert to a known good state and try again.
Now we can process to the next scenario in our test list, we had failing test
it("return unit price when it's a single item in the cart", () => {
const shoppingCart: Cart[] = [
{ unitPrice: 15.0, quantity: 1, discount: 0 }
];
const shopping = new Shopping();
const total = shopping.totalize(shoppingCart);
expect(total).toBe(15.0);
});
// ❌ At this stage our test fail
and in production code, we had the minimum code sufficient to make the failing test pass
export default class Shopping {
public totalize(cart: Cart[]): number {
if (cart.length === 0) {
return 0;
}
return 15.0;
}
}
// ✅ At this stage our test pass
Now we can proceed to the refactor (actually generalization of the solution) step
export default class Shopping {
public totalize(cart: Cart[]): number {
if (cart.length === 0) {
return 0;
}
return cart[0].unitPrice * cart[0].quantity - cart[0].discount;
}
}
// ✅ At this stage all our tests still pass
Part of the TDD mentality is that every passing test represents a proven piece of behaviour that you’ve added to the system.
The more you think in incremental terms, and the more frequently you seek to integrate your locally proven behaviour, the more successful you’ll be.
Fix Unclean Code
Keep in mind that it’s straightforward to introduce deficient code, even in a few lines. TDD provides a fantastic opportunity to resolve such minor problems as they arise before they add up to countless minor issues.
At any given point, your complete set of tests declares the behaviours you intend your system to have. That implies if no test describes a behaviour, it either doesn’t exist or isn’t intended (or the tests do a poor job of describing behaviour).
Incrementalism
Incrementalism is at the heart of what makes TDD successful. An incremental approach will seem quite unnatural and slow at first.
However, taking small steps will increase your seed over time, partly because you will avoid errors that arise from taking significant, complicated steps.
Each test we add is independent. We don’t use the outcome of one test as a precondition for running another. Each test must set up its own context. (For example — a new test should create its own instance of production class in which behaviour is tested).
When done test-driving, you’ll know that the tests correctly describe how your system works, as long as they pass. In addition, they provide examples that can be read easier than specs if crafted well.
Fixture and Setup
Not only do we want to look at production code for refactoring opportunities, but we also want to look at the tests, too, because it can add up quickly and often turn into more complex duplication.
At this point, we can apply all the steps you can see in the picture above, to refactor both the production and the test code.
Thinking and TDD
The cycle of TDD, once again, briefly, is to write a small test, ensure it fails, get it to pass, review and clean up the design (including that of the tests), and ensure the tests all still pass. Repeat this cycle throughout the day, keeping it short to maximize the feedback it gives you.
Remember that, technically, our job is to make the test pass, after which our job is to clean up the solution.
However, the implementation we seek generalizes our solution — but does not over-generalize it to support additional concerns - and does not introduce code that duplicates concepts already coded.
Now we can continue with our practical example, the next scenario is to return the total price when we have more than 1 item in the shopping cart.
it("return total price when it's a single item in the cart", () => {
const shoppingCart: Cart[] = [
{ unitPrice: 12.0, quantity: 2, discount: 0 },
{ unitPrice: 30.0, quantity: 4, discount: 5.5 },
{ unitPrice: 20.0, quantity: 1, discount: 4 }
];
const total = shopping.totalize(shoppingCart);
expect(total).toBe(164.5);
});
// ❌ At this stage our test fail
In our production code, once again, we write the minimum code that is sufficient to make a failing test pass.
export default class Shopping {
public totalize(cart: Cart[]): number {
if (this.isEmptyCart(cart)) {
return 0;
}
if (cart.length === 1) {
return this.totalOfSingleItem(cart,0)
}
return 164.5;
}
private totalOfSingleItem(cart: Cart[], itemIndex: number): number {
return (cart[itemIndex].unitPrice * cart[itemIndex].quantity - cart[itemIndex].discount);
}
private isEmptyCart(cart: Cart[]): boolean {
return cart.length === 0;
}
}
// ✅ At this stage our test pass
Now we generalize and refactor if needed.
export default class Shopping {
public totalize(cart: Cart[]): number {
if (this.isEmptyCart(cart)) { return 0; }
if (this.isSingleItem(cart)) { return this.totalOfSingleItem(cart, 0); }
const total: number[] = [];
for (let itemIndex = 0; itemIndex < cart.length; itemIndex++) {
total.push(this.totalOfSingleItem(cart,itemIndex));
}
return total.reduce((prev, next) => prev + next, 0);
}
private totalOfSingleItem(cart: Cart[], itemIndex: number): number {
return cart[itemIndex].unitPrice * cart[itemIndex].quantity - cart[itemIndex].discount;
}
private isSingleItem(cart: Cart[]): boolean { return cart.length === 1; }
private isEmptyCart(cart: Cart[]): boolean { return cart.length === 0; }
}
// ✅ At this stage all our tests pass
TDD is not a hard science; instead, think of it as a crafts person’s tool for incrementally growing a codebase. It’s a tool that accommodates continual experimentation, discovery and refinement.
Separating code in a declarative manner makes code considerably easier to understand. Because separating the interface (What) from the implementation (How) is an essential aspect of design and provides a springboard for larger design choices.
You want to consider similar restructuring every time you hit the refactoring step in TDD.
So to apply the refactoring here, it will be good to replace the variable with a method which will represent your interface (and rename that method accordingly)
Test-Driving vs Testing
The rule of thumb for TDD is one assert per test. It’s a good idea that promotes focusing on the behaviour of the tests, instead of centering tests around functions. We will follow this rule most of the time.
When using a testing technique, you would seek to analyze the specification exhaustively in question (and possibly the code) and devise tests that exhaustively cover the behaviour.
In summary, you write a test to describe the next behaviour needed. If you know that the logic won’t need to change any further; you stop writing tests.
What If?
What if some behaviours that don’t exist in your scenarios appear? If so, what should the function do? – I recommend you to write down tests for some exceptional cases that could happen because it will save you tons of debugging time later.
If we have a special case in our practical example, if we have a quantity === 0
, when we have an item in the cart, it doesn’t make sense.
// ...
it("return an error when quantity is O and an item exist in the cart", () => {
expect(()=> {
shopping.totalize([{ unitPrice: 10.0, quantity: 0, discount: 0 }]);
}).toThrowError(new Error('Quantity 0 is not allowed when it\'s an item in cart'));
});
// ...
// We add our failing test
// ❌ At this stage our test fail
Now we can add the minimum code to make our failing test pass
// ...
if (this.isSingleItem(cart)) {
if(cart[0].quantity === 0) { throw new Error('Quantity 0 is not allowed when it\'s an item in cart'); }
return this.totalOfSingleItem(cart, 0);
}
// ...
Then we can refactor the production code
// ...
if (this.isSingleItem(cart)) {
if(this.isQuantityNumberValid(cart, 0)) { throw new Error('Quantity 0 is not allowed when it\'s an item in cart'); }
return this.totalOfSingleItem(cart, 0);
}
// ...
// ...
private isQuantityNumberValid(cart: Cart[], index: number): boolean { return cart[index].quantity === 0; }
// ✅ At this stage all our tests pass
We can apply the same verification when the cart has many items
// ...
it("return an error when quantity is O and many items exist in the cart", () => {
const shoppingCart: Cart[] = [
{ unitPrice: 10.0, quantity: 0, discount: 0 },
{ unitPrice: 30.0, quantity: 4, discount: 6 }
];
expect(()=> {
shopping.totalize(shoppingCart);
}).toThrowError(new Error('Quantity 0 is not allowed when it\'s an item in cart'));
});
// ...
// ❌ At this stage our test fail
Then we can the production code to make our test pass
// ...
const total: number[] = [];
for (let itemIndex = 0; itemIndex < cart.length; itemIndex++) {
if(this.isQuantityNumberNotValid(cart, itemIndex)) { throw new Error('Quantity 0 is not allowed when it\'s an item in cart'); }
total.push(this.totalOfSingleItem(cart,itemIndex));
}
// ...
// ✅ At this stage all our tests pass
After we have made our test pass and applied last refactoring, this is what our production code look like
export default class Shopping {
public totalize(cart: Cart[]): number {
if (this.isEmptyCart(cart)) { return 0; }
if (this.isSingleItem(cart)) {
this.checkQuantityNumber(cart, 0);
return this.totalOfSingleItem(cart, 0);
}
const total: number[] = [];
for (let itemIndex = 0; itemIndex < cart.length; itemIndex++) {
this.checkQuantityNumber(cart, itemIndex);
total.push(this.totalOfSingleItem(cart,itemIndex));
}
return total.reduce((prev, next) => prev + next, 0);
}
private checkQuantityNumber(cart: Cart[], index:number): void {
if (this.isQuantityNumberNotValid(cart, index)) { throw new Error('Quantity 0 is not allowed when it\\'s an item in cart'); }
}
private isQuantityNumberNotValid(cart: Cart[], index: number): boolean { return cart[index].quantity === 0; }
private totalOfSingleItem(cart: Cart[], itemIndex: number): number {
return cart[itemIndex].unitPrice * cart[itemIndex].quantity - cart[itemIndex].discount;
}
private isSingleItem(cart: Cart[]): boolean { return cart.length === 1; }
private isEmptyCart(cart: Cart[]): boolean { return cart.length === 0; }
}
// ✅ At this stage all our tests pass
Doing What It Takes to Clarify Tests
Usually, a test that passes with no change to your classes is always an indicator to take a pause. Ask yourself what I might have done differently?
After we have to clarify all our tests and applied the refactoring steps, we have all our tests passed. 🥳
I hope you enjoyed reading this, and I'm curious to hear if this tutorial helped you. Please let me know your thoughts below in the comments. Don't forget to subscribe to my newsletter to avoid missing my upcoming blog posts.
You can also find me here LinkedIn • Twitter • GitHub or Medium
Personal Thoughts
I think TDD is an excellent development technique because it allows you to remain confident once the code has been pushed, and the computer has been closed.
But from my experience, I've realized that the learning curve is quite long because it's a new way of thinking about code, very different from the classical teachings.
And putting it into practice in a real production context is still a bit unclear (to me anyway). My opinion is not to discourage a person who would like to start TDD, but rather to understand that it's worth the effort.
Summary
This article explores all these topics :
- The definition of unit
- The TDD cycle of red-green-refactor
- The three rules of TDD
- Why you should never skip observing test failure
- Mind-sets for success
- Mechanics for success
And I encourage you to explore them in details for you as well, you can get the code of this practical on my GitHub.