In general, when modeling phenomena in science and engineering, we begin with simplified, incomplete models. As we examine things in greater detail, these simple models become inadequate and must be replaced by more refined models. –Structure and Interpretation of Computer Programs
What is Test-driven development(TDD)?
Test-driven development (TDD) is a software development process relying on software requirements being converted to test cases before the software is fully developed, and tracking all software development by repeatedly testing the software against all test cases. This is as opposed to software being developed first and test cases created later.
Test-driven development cycle
A TDD cycle follows those steps:
Add a test: At the beginning of adding a feature, first add a test according to the requirement(use case or use story).
Run all tests: We will run all tests and new tests should fail for the expected reasons. This ensures our test is working correctly instead of passing all the time.
Write the simplest code that passes the new test: No code should be added beyond the tested functionality. (The code will be honed anyway in step 5.)
All tests should now pass: If any fail, the new code must be revised until they pass. This ensures the new code meets the requirement and doesn’t break existing features.
Refactor as needed: Code is refactored for readability and maintainability, keep using tests after each refactor. Some examples of refactoring:
Repeat: The cycle above is repeated for each new feature until all requirements are met. Use version control(commit) often so if new code fails some tests, you can simply revert rather than debugging excessively.
For simply, we call it: test-driven development mantra–red/green/refactor:
Benefit
TDD has lots of proven benefit, some of them are:
It fits the way that modeling phenomena in engineering, which is to start at simplied and incomplete model, and when examine the model in greater details, it become inadequate and must be replaced by more refined model.
Because it requires developer to write tests before writing code:
It makes developer focus on requirements before writing code.
It forces developer to write code with testability.
It helps developer to focus on software quality.
Because developer is required to write simplest code to pass the tests:
It meets the principles of “Keep it simple and stupid”(KISS) and “You aren’t gonna need it”(YAGNI).
Developer trend to keep the codebase more simple, and it prevents developer from introducing unnecessary code.
Because it can work with git, developer can simply undo or revert when he makes change and failed some tests, instead of spending too much time debugging.
Because each test case fails initially, it ensures that the test really works and can each error rather than pass all the time.
Because TDD trend to write simplest feature’s test each time:
It can serve as documentation: small test cases are easy to understand.
It can reduce debugging effort: small test cases help track error more precisely.
Best practices
To get benefits above, developer needs to follow some best practices.
3 laws of TDD
You must write a failing test before you write any production code.
You must not write more of a test than is sufficient to fail, or fail to compile.
You must not write more production code than is sufficient to make the currently failling test pass.
Test-driving guided by zombies
Zombies testing is a way to think about where to start Read more, and how to write next test:
Zero: You need to write for the test case of zero things being pass to the module, and zero thing being return from the module.(special case)
One: Then you start thinking about one thing being pass or one thing being return.(special case)
Many: Finally you get to the many case.(general case)
Interfaces: Early tests focus on interface. When you finished Zero to One steps, your interfaces are defined.
Boundaries Behaviors: What it will behaviors when it meets boundaries?(Zero and One, Full)
Exceptions: Don’t forget about the exception.(For example, wrong input format, wrong value)
Simple scenarios, Simple solutions: Keep the scenarios and solutions simplied as far as possible.
Examples
Example 1: A simple number stack class
Requirement:
We can push numbers in the stack.
We can pop numbers that we just push.
The order should be FILO(first in last out).
Flow:
First, according to the requirement, we can write our first test from zero cases:
1 2 3 4 5 6
it('should return false when pop in an empty stack', () => { const stack = newStack()
expect(stack.pop()).toBe(false) })
It should fail because we don’t even create the class. We write the simplest code to pass the test:
1 2 3 4 5 6 7 8 9
classStack { constructor() { }
pop() { returnfalse } }
Now the test is passed.
You might think it is ridiculous to write a hardcode like this, and say that when will eventually change the implementation anyway, it’s totally wasting time. Well, it isn’t. As you can see, although your implementation is naive at this step, you have done two things:
You defined the interface of the unit.
You added the test for the zero case, and understand the requirement of this special case.
Let’s continue to write tests for the one case:
1 2 3 4 5 6 7 8
it('should return 1 when we push 1 to an empty stack and then pop', () => { const stack = newStack()
stack.push(1)
expect(stack.pop()).toBe(1) })
It failed because we don’t consider the one case previously, so we change the code to pass it:
push(number) { this.isEmpty = false this.number = number } }
So now our tests pass again, we can see at this point we have already created two interfaces, which are pop and push, even though they can only handle the zero and one case.
We continue by making the class more general. We start to consider the many cases.
1 2 3 4 5 6 7 8 9 10 11 12
it('should return 1 2 3 when we push 3 2 1 to an empty stack and then pop', () => { const stack = newStack()
As all of the tests pass again, now we have confidence that our code works great from zero to many cases. We can continue by considering the Exception cases: what if our user doesn’t pass a number? We can simply throw an error with some information to our user:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
it(`should throw an error with msg: "invalid type, please push a number" when input isn t a number`, () => { const stack = newStack()
// if you want to expect a function throwing error in jest, you should wrap it // in a function an pass to expect instead of calling it directly. Otherwise it // can't be catched by expect. functionshouldThrowError() { stack.push(`I'm a string`) }
expect(shouldThrowError).toThrowError('invalid type, please push a number') })
pop() { if(this.top === -1) returnfalse const record = this.list[this.top] this.top = this.top - 1 return record }
push(number) { if(typeof number !== 'number') thrownewError('invalid type, please push a number') this.top = this.top + 1 this.list[this.top] = number } }
So, we consider all the cases(hopefully) that this class will meet, and write both tests and code for the class, now we shall have the confidence to say that our code is robust and maintainable for both user and developer.
What’s more, when you refactor the class, you don’t need to be afraid that your new code will cause some regression, after all, you pass all the tests.