User-Driven Development (my take on Test Driven Development)

User-Driven Development (my take on Test Driven Development)
SHARE

Test Example Driven Development

Test Driven Development (I’ll refer to it as TDD from now on) is a software design technique that has been created and sponsored by the mighty Kent Beck, one of the 17 original signatories of the Agile Manifesto.

TDD is quite straightforward, and it’s best explained through an example.

Let’s say we want to develop a function to calculate an employee’s salary by counting the days worked, taking into consideration the holidays taken and sick days used. How would we go about that?

Let’s put ourselves in the shoes of our function’s users. How would they want to use that function? Let’s write an example:

const employee = {
  salaryPerDay: 100,
  daysWorked: 250,
  holidaysTaken: 15,
  sickDaysUsed: 10,
}

const yearlySalary = calculateEmployeeSalary(employee)
console.log(yearlySalary) // 27300

Is this function any good? Maybe, let’s say it is. TDD puts you in the right mindset to create a user-friendly, maintainable design. And when I say “user” I mean your fellow colleague or yourself in 2 years.

Now, this code is going to fail of course, because the function doesn’t exist yet.

ReferenceError: calculateEmployeeSalary is not defined
    at <anonymous>:8:20
    at dn (<anonymous>:16:5449)

One of the secret ingredients of TDD is making very small steps. Always ask yourself, what’s the smallest thing I could do to get some progress? For the code above, we just need to create the function signature, so to solve the error.

function calculateEmployeeSalary(employee) {}

When I execute the code I now get:

undefined

Which is fine, because the function is not implemented, yet. We’re expecting the console to print 27300, what’s the smallest step we can take to make it happen? Simple.

function calculateEmployeeSalary(employee) {
  return 27300
}

You’ll be thinking, well, thanks a lot for that! I want you to trust me, this will make more sense later in the article. It’s always about making small steps.

Now it’s time to implement our function (thanks ChatGPT for that):

function calculateEmployeeSalary(employee) {
  let totalSalary = 0

  totalSalary += employee.salaryPerDay * employee.daysWorked

  if (employee.holidaysTaken > 40) {
    totalSalary -= employee.salaryPerDay * (employee.holidaysTaken - 40)
  } else {
    totalSalary += employee.salaryPerDay * employee.holidaysTaken
  }

  totalSalary += employee.salaryPerDay * employee.sickDaysUsed * 0.8

  return totalSalary
}

You may want to test the function with different combinations of data, so as to hit all the logic branches. In the example above, we’re in a situation where the employee has taken less than 40 holidays. Let’s create an example for the opposite situation.

const employee = {
  salaryPerDay: 100,
  daysWorked: 250,
  holidaysTaken: 50,
  sickDaysUsed: 10,
}

const yearlySalary = calculateEmployeeSalary(employee)
console.log(yearlySalary) // 29800

But wait… it prints 24800 instead 🤔 ChatGPT, you naughty robot!

The problem is that even if the holidays taken are more than 40, only the excess is subtracted from the total salary, but the initial 40 paid holidays are skipped.

Let’s fix that:

function calculateEmployeeSalary(employee) {
  let totalSalary = 0

  totalSalary +=
    employee.salaryPerDay * (employee.daysWorked + employee.holidaysTaken)

  if (employee.holidaysTaken > 40) {
    totalSalary -= employee.salaryPerDay * (employee.holidaysTaken - 40)
  }

  totalSalary += employee.salaryPerDay * employee.sickDaysUsed * 0.8

  return totalSalary
}

Now it works! We also have to run again the first example where the employees have taken less than 40 holidays. And da daaa, we get 27300.

Our job is not done, you can continue testing for edge cases, but I’ll keep them out for the sake of simplicity and to not bore you to death.

Something that I’ve done differently here compared to a more “traditional” way of programming, is starting from the example, and not from the implementation. Starting from the implementation may lead to bad design because we’re ignoring the users of the code and the context within the function is being created.

Example Test Driven Development

TDD will require us to create a test that automates the execution of the examples we wrote above so that you don’t have to run them manually every time. The test framework you’ll using is most likely capable of watching changes for the files you’re testing and re-executing them accordingly. Here’s an example of how the tests could look like. (I’m using the node-tap framework for Node)

test("calculateEmployeeSalary - correct salary calculation with less 40 holidays", (t) => {
  const employee = {
    salaryPerDay: 100,
    daysWorked: 250,
    holidaysTaken: 15,
    sickDaysUsed: 10,
  }

  const expectedResult = 27300
  const actualResult = calculateEmployeeSalary(employee)
  t.equal(actualResult, expectedResult)
  t.end()
})

test("calculateEmployeeSalary - correct salary calculation with over 40 holidays", (t) => {
  const employee = {
    salaryPerDay: 100,
    daysWorked: 250,
    holidaysTaken: 50,
    sickDaysUsed: 10,
  }

  const expectedResult = 29800
  const actualResult = calculateEmployeeSalary(employee)
  t.equal(actualResult, expectedResult)
  t.end()
})

The next steps are the same as above, but this time we start from a broken test, then we try to make it pass by working on the function implementation in small steps and without caring much about clean code. Once you get it right, it’s time to refactor it.

This is the famous red, green, refactor cycle that TDD lovers like to brag about.

Untitled_Artwork.png

Note that TDD can be extended for any component of the system you work on. For example, I use it extensively to design WEB APIs using “component testing” (you can find an example here) (create something, get it, updated it, get it again to check the update, delete it, get it again to check it’s not there). You can also use it to design the API of UI components.

Benefits of TDD

Untitled_Artwork 2.png

Increased Productivity

TDD boosts productivity by automating the execution of the code you’re writing and giving you immediate feedback on your progress. Breaking the code? You’ll know it. Doing a good job? Don’t be so happy, because you still need to do some refactoring!

Better Software Design

TDD it’s a powerful tool for designing software because it encourages developers to focus on the behavior of the system, rather than its implementation. Writing tests before writing code forces developers to think about the inputs and outputs of the code and how it should behave in different scenarios. This helps to ensure that the code is designed in a way that is easy to test and understand, and that it meets the needs of the users.

Improved maintainability and living documentation

Even though TDD is not really a testing strategy, but a designing strategy, we get a set of automated tests for free which help us create "living documentation" I’ll for the system, making it easier to understand how it works. As a result, making changes or updates to the code is less risky, because the tests can be run to ensure that the changes haven't introduced any new bugs.

A good test suite makes it easier to refactor the code, as the tests are there to ensure that the system functions as intended, and developers can make changes to the codebase with more confidence, knowing that if something breaks, it will be caught by the tests.

Maintainability is also improved thanks to the creation of modular, well-organized code, which is less prone to errors and easier to understand.

Working with legacy code

TDD is a great tool when working with legacy systems, which we may have no clue how they work, may not have a good test suite and so makes us afraid to change even one line of code. By writing some tests around the existing behavior BEFORE making any change, we can preserve the current behavior of the system and avoid hours of suffering trying to understand what we broke.

Communication

TDD could be used during planning with your team. Let’s say you’re in a discussion about a user story and you start writing down a series of scenarios that need to be implemented. You could write a test with an example of those scenarios WHILE doing the planning, and then use it as a base to start designing the technical solution.

Reduced costs for the business

All the points above clearly bring a reduction in costs for the business, wasting less time chasing bugs, reducing the time developers use to understand the code, and also speeding up onboarding for new joiners.

Fixing bugs

To use TDD to solve a bug, the first step is to write a test that reproduces the bug. This test should be designed to specifically target the problem and when it is run, it should fail because the bug exists. Once the test reproduces the bug, the next step is to write code to fix the bug. The goal is to make the test pass by writing minimal code changes.

Once the bug is fixed, the test should pass and the developer can ensure that the fix works correctly and that the bug doesn't reoccur. Additionally, once the test case is added to the codebase, it will ensure that the same bug won't happen again in the future by catching it early on if it reappears.

Cons of TDD

Untitled_Artwork 3.png

Difficulty in testing specific types of code

TDD may be difficult or impossible to use for certain types of code, such as code with complex logic or code that is tightly coupled to external systems.

Challenges in testing legacy code

It may be difficult or impossible to test legacy code because it was not originally written with testing in mind, which can make it hard to write automated tests.

Challenges in testing edge cases

In some cases, it can be hard to anticipate all possible edge cases, so some bugs may go undetected by the test suite, especially if the developer doesn't have enough experience.

Increased development time

Writing tests before writing code can add extra time to develop, as developers must write and run tests in addition to writing code. But aren’t we supposed to write tests anyways?

TestUser Driven Development

Untitled_Artwork 2.png

TDD is not a testing strategy, but a design strategy. Tests are nice side effects and they are not to be thrown away, but the real protagonist is the users. For users I mean:

  • The product’s users
  • The code’s users
  • The API’s users
  • The business

We design the external APIs of our system by interacting with them as if we were the user of the product.

We design the internal APIs of our system by thinking about how easily they would be understood by our fellow colleagues or by ourselves in the future.

We design our system to have a testable architecture so that it can be maintained easily and avoid the business wasting time and money on chasing bugs.

Is it a surprise that we develop code for someone else, and not for us?

Follow me on Twitter: @peppesilletti