There isn’t much that compares to the feeling of deploying your latest changes to production and finding out the features don’t work. A good TDD strategy can help prevent the stress and anxiety of production level bugs.
These scenarios will be a rite-of-passage if you ever find yourself in such predicaments. Early in my career as a fresh bright-eyed software developer, I was on production updating a platform and I brought the system down for 10 minutes. This is an experience I’ll never forget and helped me become a better software engineer.
I don’t want to blame it entirely on the company I was working for, but they had no concept of TDD or Test Driven Development. Worse yet, they had little to no test coverage in general. If it wasn’t me that was going to make that mistake, it was another developer.
"Checking Out" TDD
You may do some research and find that Kent Beck is attributed to what we now know as TDD. However, with a bit more digging you’ll see that designing software with tests has been around since the beginning of software development. Back then author Daniel McCracken was making a case for “Checking Out” his software while writing it.
In its most simple form Test Driven Development is writing tests before or during development of the software. Daniel McCracken writes in his book that “In order to fully ascertain the accuracy of the answers, it is necessary to have a hand-calculated check case with which to compare the answers which will later be calculated by the machine.” In short: Write test cases to prove the code works.
Test Driven Development strategies will include several types of testing. I’ll be covering the basic pillars of testing your software. These pillars include unit tests, integration tests, and end-to-end testing.
There are many types of testing and they can all be mixed and matched to create a solid TDD strategy.
Unit Tests and TDD
A key ingredient to a good TDD strategy is to test as granular as possible. Unit tests do just that. When correctly tested, you can be confident that every unit or method in your class or function set is working as intended.
A fruity example
Let’s take a fake ordering platform “Fruit Stand.” Fruit Stand allows end users to purchase fruit from an app on their phone. The following unit test will ensure that the getTotal function is working on the Order class.
Note: All the following is pseudo-code and should not be copy-pasted.
let fruit = new Fruit(2.99); // Accepts price in constructor
let order = new Order();
let out = order.getTotal(fruit,20);
Let's take a peek into how getTotal is defined.
class Order {
details: string;
getTotal(fruit: Fruit, qty: number) {
return fruit.getPrice() * qty;
}
setDetails(details: string) {
this.details = details;
}
getDetails() {
return details;
}
}
You’ll see that we pass in the Fruit class as an argument. This tactic is known as dependency injection and helps our software become more testable.
Creating the test
Our unit test will test the single function getTotal. In the test, we’ll create a new mock Fruit, pass it and a quantity variable to an instantiated Order, and run assertions.
In order to write a test for this at the unit level we will want to create a Fruit “double” or mock class. This is because getPrice is used in the getTotal function of the Order class. The true functionality of getPrice is outside the scope of this unit test.
class MockFruit extends Fruit{
getPrice() {
return 1;
}
}
There are numerous testing frameworks out there to help you setup mock objects, test doubles, fakes and spies – but that’s beyond the scope of this article. Simply put, the above mock is a way to create a test double, but not necessarily the best way.
Asserting the unit test
Since our mock object returns 1 as the price, we can expect the quantity to be equal to the return value of getTotal. Additionally, we’ll want ensure that the getPrice function was in fact called on the Fruit class.
test(“Test Order getTotal”, () => {
const fruit = new MockFruit()
// We want to be able to determine getPrice was called
const spy = spy(fruit);
const order = new Order();
const orderTotal = order.getTotal(fruit,20);
// Assert total to return proper price
expect(orderTotal).toBe(20)
// Assert getPrice to return proper price
expect(spy.getPrice).toHaveBeenCalled();
});
With the test above, we can be sure of two things:
1) The getTotal is returning the correct price
2) The getPrice function is firing correctly from getTotal
Unit tests are small in scope and should make sure or assert that certain conditions are true on a granular level. You’ll notice our tests don’t validate whether or not the getPrice function is working as intended. This is because it needs its own unit test. A good TDD strategy will have a large number of unit tests. This will ensure things are working on the smallest level and will reduce bugs in production.
TDD and Integration Tests
Testing many functions or class methods together is known as Integration Testing. The integration test should be relatively small in scope and cover only a handful of functions.
A simple example
Taking our fruity example we can build an integration test to ensure the Fruit, and the Order classes work together in tandem. Let’s see how the two are defined:
// Fruit.*
class Fruit {
name: string;
price: number;
getPrice() {
return this.price;
}
}
// Order.*
class Order {
details: string;
getTotal(fruit: Fruit, qty: number) {
return fruit.getPrice() * qty;
}
setDetails(details: string) {
this.details = details;
}
getDetails() {
return details;
}
}
When we Unit Tested Order our TDD strategy was to test the individual function getTotal. Let’s test that same function, but in an integration test.
test("Test Fruit & Order Integration", () => {
const fruit = new Fruit(10)
const order = new Order();
const orderTotal = order.getTotal(fruit,20);
// Assert total to return proper price
expect(orderTotal).toBe(200);
});
It may seem kind of strange to test the getTotal function again in an integration test, but this test allows us to assert the following.
1) The function orderTotal is returning the correct total using a non-mock Fruit object
2) The Fruit object is correctly returning it’s price in the context of this integration
3) Order is correctly calculating the total using a non-mock Fruit object
Are integration tests redundant?
Yes and no. They do test single units but mainly focus on their integrations with other functions or classes. It may feel redundant, but true redundancy in testing is hard to achieve – unless of course you write the same exact test twice.
Complex Systems require good integration tests
In this rudimentary example it seems kind of silly to do this kind of testing. But if we continue developing this application and end up changing how the method getPrice works, we’ll want to be absolutely positive that it still works as expected with other parts of the code. Changes in complex systems require our new code to work as expected with previous tests. Integration test failures will rear their head in regression testing and we’ll know if they work or not.
Integration Tests should be built anytime you connect two or more of your components together in the system. If you have many components working together, it may be worth writing several integration tests for the same sequence using mocks or doubles. However, the mocks or doubles should be never add doubt to the integration test.
Include End-to-End testing in your TDD Strategy
End-to-End testing is a strategy that tests your application from the UI/UX level all the way down to the datastore layer. There’s a couple of technologies that I’ve used that come to mind, like Cypress and Selenium. Cypress markets itself as an end-to-end testing platform and synergizes beautifully with Jest and other assertion technology. Selenium, on the other hand, promotes itself strictly as browser automation. Both are designed for web applications and meant to run the browser automatically.
Automated End-To-End Testing will save tons of time.
Another method is clicking through defined sets of sequences and protocols by hand to provide manual end-to-end testing. However, this is an arduous and time consuming task. A proper TDD strategy will take advantage of automation technologies to implement end-to-end testing procedures that mitigate production bugs.
Automated testing works just like any other testing. Each framework is a bit different, but at the end of the day you run a set of actions and then assert bits of data and expectations that the platform should have.
For example, when I am writing Cypress tests, I like to intercept the API calls and assert that the data is as expected. Additionally, I like to assert that certain DOMs contain the correct data and read correctly.
Implementing End-To-End technologies in a TDD strategy will increase your confidence in your code and application. You can rest better knowing the end-user’s experience has been tested and is working as intended.
A strong TDD strategy
Having a good TDD strategy will make you feel more confident about releasing code.
Making new changes, updates or large scale features will seem less daunting. This is because you know that you have the means to check the many corners and dark recesses of your code.
There is a double-edged sword aspect to testing. Test Suites can become too large. These large test suites take time and resources to run. You may have automatic testing through a CI/CD pipeline or you simply press “Run Tests” on your dev machine and make a cup of coffee. Either way, resources need to be used to perform these tests.
There is a concept called selective testing that may come in handy. It is the idea to run segmented test suites instead of the platform’s entire test base.
In this writer’s opinion having too many tests is a good problem to have; having too few tests is a bad problem to have.
Developing a good TDD strategy will include unit tests, integration tests, and end-to-end testing. These methodologies will increase your confidence in your code, prevent bugs in production, and overall just make the developing experience much more sane.