Red, green, refactor

Translate from one human language to another!
Lang to lang

Need a better translation or explanation?
Click on the image, on how AI can help you.
(Opens in a new window)

Introduction on how to write a test before code

It can be very difficult to grasp, on how to write a test case before writing code.
Many ask: “How can you test something that doesn’t exist yet?”

It is because we need to view development from a different angle.
Software is the logic that connects the expected inputs with the expected outputs.

In test driven development (TDD) we take one input and output and write them down as a test case. This test case will fail, since there is no code to support it (❌ RED)
Then we can write the needed code/logic to make the test pass (βœ… GREEN)

Then we take another input and output and write it down as another test case.
This test case will most likely also fail (❌ RED).
We will then update the code/logic to make the test pass (βœ… GREEN).
Then we need to make sure that all the previous test cases also pass. If not, then we need to refactor the code to make them pass (♻️ Refactor)

This is where
❌ RED β†’ βœ… GREEN β†’ ♻️ Refactor
comes from.

Music Intro

Exercise (Waterfall Method or Cowboy Coding)

Let’s try to implement a calendar.
A calendar is something everybody can use, but it is not as easy to specify.

Choose to either A or B:

A – Waterfall Method: Try to write down a specification for a calendar.
(use 5 and 15 min.)

B – Cowboy Coding: Try to code the calendar without any planning.
(use between 5 and 15 min.)

Then review your specification with the following questions:

  1. How easy was it?
  2. How do you know, if you have all the critical parts?
  3. What parts are non-critical?
  4. How do you know a part is critical or not?

Fun observations

I prefer to call the Waterfall method for “Waterfall Model Development (WMD)” because the acronym also means Weapons of Mass Destruction and it is not entirely false πŸ˜‚)

But Cowboy Coding is not much better:
“Days of coding can save you minutes of planning” πŸ˜‚πŸ˜‚

Waterfall method and Cowboy coding both bring their own value to the table.
TDD tries to combine them into tiny waterfalls, where each test case is a waterfall method. (we could call TDD for Minimum Viable Waterfalls πŸ˜‚πŸ˜‚πŸ˜‚)

Minimum Viable Product

TDD is about making a minimum viable product, because we start with the most important test case and then add another test cases, then another.

Of course, we might learn under the development that something was more important than we thought previously, which we then write down as a test case, so it can be implemented.
We can always remake the priority list (if we wrote the priority down).

Our program will then grow, based on our experience and by each test case we will learn what is actually critical for the product. When all the critical parts are implemented, then we will have the minimum viable product.

Let’s try that.

TDD method: Alternative to the waterfall and cowboy coding

Write down the most critical input and output that a calendar must handle.
i.e. handling a date:

when we create the calendar with date "2024-07-12"
then we must be able to print it as "2024-07-12"
πŸ₯’ Gherkin

This test will fail, until we create the Calendar class:

Python
class Calendar:
    def __init__(self, dateString):
        self.dateString = dateString 

    def __str__(self):
        return self.dateString
        
πŸ’» Code

We don’t need to think about how to store the date values or anything yet.
Currently the first test only needs to store a string value and be able to print it.

Adding Test Design Techniques

Then we add another critical input and output.
This is where test design techniques are handy.
Let start with boundary-value-analysis where we test for minimum and maximum value:

when we create the calendar with date <input_date>
then we must be able to print it as <expected_data>

Examples
  description | input_date   | expected_data
  "min. date" | "2024-01-01" | "2024-01-01" 
  "max. date" | "2024-01-31" | "2024-01-31" 
πŸ₯’ Gherkin

This will pass, so the code doesn’t need to be updated.

Extend with a negative test

But with boundary-value-analysis we need to test below the minimum value, so we update the first test and add two more:

when we create the calendar with date <input_date>
then we must be able to print it as <expected_data>

Examples
  description  | input_date   | expected_data | error_message
  "min. date"  | "2024-01-01" | "2024-01-01"  | null
  "max. date"  | "2024-01-31" | "2024-01-31"  | null
  "below min." | "2024-01-01" | null          | "day below minimum 01" 
πŸ₯’ Gherkin

The first two tests pass, while the 3rd test fails, until we update the class:

Python
class Calendar:
    def __init__(self, dateString):
        year, month, day = dateString.split("-")
        if(int(day)<1):
            raise ValueError(f"The day value '{day}' is below minimum value '01'")
        self.dateString = dateString 

    def __str__(self):
        return self.dateString
πŸ’» Code

Extend with another negative test

Then we Boundary-Value-Analysis requires us to test for above the max. value:

when we create the calendar with date <input_date>
then we must be able to print it as <expected_data>

Examples
  description  | input_date   | expected_data | error_message
  "min. date"  | "2024-01-01" | "2024-01-01"  | null
  "max. date"  | "2024-01-31" | "2024-01-31"  | null
  "below min." | "2024-01-01" | null          | "day below minimum 01" 
  "above max." | "2024-01-32" | null          | "day above maximum 31" 
πŸ₯’ Gherkin

Which again fails and we need to update the class:

Python
class Calendar:
    def __init__(self, dateString):
        year, month, day = dateString.split("-")
        if(int(day)<1):
            raise ValueError(f"The day value '{day}' is below minimum value '01'")
        
        elif(int(day)>31):
            raise ValueError(f"The day value '{day}' is above maximum value '31'")
        
        self.dateString = dateString 

    def __str__(self):
        return self.dateString
πŸ’» Code

Extend until fail

We will skip February to begin with.
Then we will apply the boundary value analysis to March (which it will pass) and April (for which it will fail):

when we create the calendar with date <input_date>
then we must be able to print it as <expected_data>

Examples
  description  | input_date   | expected_data | error_message
  "Jan below min." | "2024-01-01" | null          | "day below minimum 01" 
  "Jan min. date"  | "2024-01-01" | "2024-01-01"  | null
  "Jan max. date"  | "2024-01-31" | "2024-01-31"  | null
  "Jan above max." | "2024-01-32" | null          | "day above maximum 31" 
  "Mar max. date"  | "2024-03-31" | "2024-03-31"  | null
  "Mar above max." | "2024-03-32" | null          | "day above maximum 31" 
  "Apr max. date"  | "2024-04-30" | "2024-04-30"  | null
  "Apr above max." | "2024-04-31" | null          | "day above maximum 30" 
πŸ₯’ Gherkin

So, we update our Calendar class. We can also refactor the class with the method: “checkAboveMax()” to make it easier to understand:

Python
class Calendar:
    monthsWith31Days = ["01", "03"]

    def __init__(self, dateString):
        year, month, day = dateString.split("-")
        if(int(day)<1):
            raise ValueError(f"The day value '{day}' is below minimum value '01'")
        if(month in self.monthsWith31Days):
            self.checkAboveMax(day, 31)
        else:
            self.checkAboveMax(day, 30)
        self.dateString = dateString 

    def checkAboveMax(self, day, max):
        if(int(day)>max):
            raise ValueError(f"The day value '{day}' is above maximum value '{max}'")

    def __str__(self):
        return self.dateString
πŸ’» Code

Extending and grouping

Now we can extend this for each other month (except February) and group them into 31 and 30 day months:

when we create the calendar with date <input_date>
then we must be able to print it as <expected_data>

Examples
  description  | input_date   | expected_data | error_message
  "Jan below min." | "2024-01-01" | null          | "day below minimum 01" 
  "Jan min. date"  | "2024-01-01" | "2024-01-01"  | null
  # 31 day months
  "Jan max. date"  | "2024-01-31" | "2024-01-31"  | null
  "Jan above max." | "2024-01-32" | null          | "day above maximum 31" 
  "Mar max. date"  | "2024-03-31" | "2024-03-31"  | null
  "Mar above max." | "2024-03-32" | null          | "day above maximum 31" 
  "May max. date"  | "2024-05-31" | "2024-05-31"  | null
  "May above max." | "2024-05-32" | null          | "day above maximum 31" 
  "Jul max. date"  | "2024-07-31" | "2024-07-31"  | null
  "Jul above max." | "2024-07-32" | null          | "day above maximum 31" 
  "Aug max. date"  | "2024-08-31" | "2024-08-31"  | null
  "Aug above max." | "2024-08-32" | null          | "day above maximum 31" 
  "Oct max. date"  | "2024-10-31" | "2024-10-31"  | null
  "Oct above max." | "2024-10-32" | null          | "day above maximum 31" 
  "Dec max. date"  | "2024-12-31" | "2024-12-31"  | null
  "Dec above max." | "2024-12-32" | null          | "day above maximum 31" 
  # 30 day months
  "Apr max. date"  | "2024-04-30" | "2024-04-30"  | null
  "Apr above max." | "2024-04-31" | null          | "day above maximum 30"
  "Jun max. date"  | "2024-06-30" | "2024-06-30"  | null
  "Jun above max." | "2024-06-31" | null          | "day above maximum 30"   
  "Sep max. date"  | "2024-09-30" | "2024-09-30"  | null
  "Sep above max." | "2024-09-31" | null          | "day above maximum 30" 
  "Nov max. date"  | "2024-11-30" | "2024-11-30"  | null
  "Nov above max." | "2024-11-31" | null          | "day above maximum 30" 
πŸ₯’ Gherkin

Where we need to update our class again (just a single line):

Python
class Calendar:
    monthsWith31Days = ["01", "03", "05", "07", "08", "10", "12"]

    def __init__(self, dateString):
        year, month, day = dateString.split("-")
        if(int(day)<1):
            raise ValueError(f"The day value '{day}' is below minimum value '01'")
        if(month in self.monthsWith31Days):
            self.checkAboveMax(day, 31)
        else:
            self.checkAboveMax(day, 30)
        self.dateString = dateString 

    def checkAboveMax(self, day, max):
        if(int(day)>max):
            raise ValueError(f"The day value '{day}' is above maximum value '{max}'")

    def __str__(self):
        return self.dateString
πŸ’» Code

Extend with exceptions

Then we can extend with other than 31 and 30 days (which is February):

when we create the calendar with date <input_date>
then we must be able to print it as <expected_data>

Examples
  description  | input_date   | expected_data | error_message
  "Jan below min." | "2024-01-01" | null          | "day below minimum 01" 
  "Jan min. date"  | "2024-01-01" | "2024-01-01"  | null
  # 31 day months
  "Jan max. date"  | "2024-01-31" | "2024-01-31"  | null
  "Jan above max." | "2024-01-32" | null          | "day above maximum 31" 
  "Mar max. date"  | "2024-03-31" | "2024-03-31"  | null
  "Mar above max." | "2024-03-32" | null          | "day above maximum 31" 
  "May max. date"  | "2024-05-31" | "2024-05-31"  | null
  "May above max." | "2024-05-32" | null          | "day above maximum 31" 
  "Jul max. date"  | "2024-07-31" | "2024-07-31"  | null
  "Jul above max." | "2024-07-32" | null          | "day above maximum 31" 
  "Aug max. date"  | "2024-08-31" | "2024-08-31"  | null
  "Aug above max." | "2024-08-32" | null          | "day above maximum 31" 
  "Oct max. date"  | "2024-10-31" | "2024-10-31"  | null
  "Oct above max." | "2024-10-32" | null          | "day above maximum 31" 
  "Dec max. date"  | "2024-12-31" | "2024-12-31"  | null
  "Dec above max." | "2024-12-32" | null          | "day above maximum 31" 
  # 30 day months
  "Apr max. date"  | "2024-04-30" | "2024-04-30"  | null
  "Apr above max." | "2024-04-31" | null          | "day above maximum 30"
  "Jun max. date"  | "2024-06-30" | "2024-06-30"  | null
  "Jun above max." | "2024-06-31" | null          | "day above maximum 30"   
  "Sep max. date"  | "2024-09-30" | "2024-09-30"  | null
  "Sep above max." | "2024-09-31" | null          | "day above maximum 30" 
  "Nov max. date"  | "2024-11-30" | "2024-11-30"  | null
  "Nov above max." | "2024-11-31" | null          | "day above maximum 30"
  # other
  "Feb max. date"  | "2024-02-29" | "2024-02-29"  | null
  "Feb above max." | "2024-02-30" | null          | "day above maximum 29"
   
πŸ₯’ Gherkin

Which will fail and we need to update our class:

Python
class Calendar:
    monthsWith31Days = ["01", "03", "05", "07", "08", "10", "12"]
    monthsWith30Days = ["04", "06", "09", "11"]

    def __init__(self, dateString):
        year, month, day = dateString.split("-")
        if(int(day)<1):
            raise ValueError(f"The day value '{day}' is below minimum value '01'")
        if(month in self.monthsWith31Days):
            self.checkAboveMax(day, 31)
        elif(month in self.monthsWith30Days):
            self.checkAboveMax(day, 30)
        else:
            self.checkAboveMax(day, 29)
        self.dateString = dateString 

    def checkAboveMax(self, day, max):
        if(int(day)>max):
            raise ValueError(f"The day value '{day}' is above maximum value '{max}'")

    def __str__(self):
        return self.dateString
πŸ’» Code

More exceptions:

Then we can extend with leap year and not leap year:

when we create the calendar with date <input_date>
then we must be able to print it as <expected_data>

Examples
  description  | input_date   | expected_data | error_message
...
  # other
  "Feb max. date (leap year)" | "2024-02-29" | "2024-02-29"  | null
  "Feb above max.(leap year)" | "2024-02-30" | null          | "day above maximum 29"
  "Feb max. date (not leap)"  | "2025-02-28" | "2025-02-28"  | null
  "Feb above max.(not leap)"  | "2025-02-29" | null          | "day above maximum 28"
   
πŸ₯’ Gherkin

And update the class to deal with the leap year:

Python
class Calendar:
    monthsWith31Days = ["01", "03", "05", "07", "08", "10", "12"]
    monthsWith30Days = ["04", "06", "09", "11"]

    def __init__(self, dateString):
        year, month, day = dateString.split("-")
        if(int(day)<1):
            raise ValueError(f"The day value '{day}' is below minimum value '01'")
        if(month in self.monthsWith31Days):
            self.checkAboveMax(day, 31)
        elif(month in self.monthsWith30Days):
            self.checkAboveMax(day, 30)
        else:
            if(self.isLeapYear(year)):
                self.checkAboveMax(day, 29)
            else:
                self.checkAboveMax(day, 28)
        self.dateString = dateString 

    def isLeapYear(self, year):
        return int(year)%4==0

    def checkAboveMax(self, day, max):
        if(int(day)>max):
            raise ValueError(f"The day value '{day}' is above maximum value '{max}'")

    def __str__(self):
        return self.dateString
πŸ’» Code

Is this critical? Then extend even more!

As we can see, the class has the minimum viable product in it and we can always ask: “what more are we missing?” and “is it critical?”

We could add the centurial leap year exception, when a leap year ends on “xx00”, i.e.: year 2100, 2200, 2300, then a leap year is not a leap year (yes, it is part of the Gregorian calendar).

The question is always: “Is the century leap year thing important for our application?”

If our app is not needed after the year 2099, then maybe this is not critical?
No need to add test cases that will never be needed or maintain the code for it them.

Let’s say we need them, so we add them to our tests:

when we create the calendar with date <input_date>
then we must be able to print it as <expected_data>

Examples
  description  | input_date   | expected_data | error_message
  ...
  # other
  "Feb max. date (leap year)"                 | "2024-02-29" | "2024-02-29"  | null
  "Feb above max.(leap year)"                 | "2024-02-30" | null          | "day above maximum 29"
  "Feb max. date (not leap)"                  | "2025-02-28" | "2025-02-28"  | null
  "Feb above max.(not leap)"                  | "2025-02-29" | null          | "day above maximum 28"
  "Feb max. date (centurial year, not leap)"  | "2100-02-28" | "2100-02-28"  | null
  "Feb above max.(centurial year, not leap)"  | "2100-02-29" | null          | "day above maximum 28"
   
πŸ₯’ Gherkin

The “centurial year, not leap” test cases will fail again, and we can refactor our isLeapYear method in our Calendar class, to make it pass without breaking any test cases:

Python
class Calendar:
    monthsWith31Days = ["01", "03", "05", "07", "08", "10", "12"]
    monthsWith30Days = ["04", "06", "09", "11"]

    def __init__(self, dateString):
        year, month, day = dateString.split("-")
        if(int(day)<1):
            raise ValueError(f"The day value '{day}' is below minimum value '01'")
        if(month in self.monthsWith31Days):
            self.checkAboveMax(day, 31)
        elif(month in self.monthsWith30Days):
            self.checkAboveMax(day, 30)
        else:
            if(self.isLeapYear(year)):
                self.checkAboveMax(day, 29)
            else:
                self.checkAboveMax(day, 28)
        self.dateString = dateString 

    def isLeapYear(self, year):
        output = False
        if(int(year)%4==0):
            if(int(year)%100!=0):
                output = True
        return output

    def checkAboveMax(self, day, max):
        if(int(day)>max):
            raise ValueError(f"The day value '{day}' is above maximum value '{max}'")

    def __str__(self):
πŸ’» Code

How deep does the rabbit whole go?

The centural leap year has an exception, that when a year ends on x400, like 1200, 1600, 2000, 2400, then a leap year (which is not a leap year, because it ends on xx00) is a leap year anyway πŸ˜‚πŸ˜‚πŸ˜‚).

Again we must ask us the question: “is this critical for our app?”
If our app is not needed for the year 2000 or 2400, then there is no need to implement it.

Let’s accept again that we need the app to work for the year 2000, so we implement the test cases:

when we create the calendar with date <input_date>
then we must be able to print it as <expected_data>

Examples
  description  | input_date   | expected_data | error_message
  ...
  # other
  "Feb max. date (leap year)"                 | "2024-02-29" | "2024-02-29"  | null
  "Feb above max.(leap year)"                 | "2024-02-30" | null          | "day above maximum 29"
  "Feb max. date (not leap)"                  | "2025-02-28" | "2025-02-28"  | null
  "Feb above max.(not leap)"                  | "2025-02-29" | null          | "day above maximum 28"
  "Feb max. date (centurial year, not leap)"  | "2100-02-28" | "2100-02-28"  | null
  "Feb above max.(centurial year, not leap)"  | "2100-02-29" | null          | "day above maximum 28"
  "Feb max. date (centurial year, leap)"    | "2000-02-29" | "2000-02-29"  | null
  "Feb above max.(centurial year, leap)"      | "2000-02-30" | null          | "day above maximum 29"
   
πŸ₯’ Gherkin

Which again will require refactoring the isLeapYear method:

Python
class Calendar:
    monthsWith31Days = ["01", "03", "05", "07", "08", "10", "12"]
    monthsWith30Days = ["04", "06", "09", "11"]

    def __init__(self, dateString):
        year, month, day = dateString.split("-")
        if(int(day)<1):
            raise ValueError(f"The day value '{day}' is below minimum value '01'")
        if(month in self.monthsWith31Days):
            self.checkAboveMax(day, 31)
        elif(month in self.monthsWith30Days):
            self.checkAboveMax(day, 30)
        else:
            if(self.isLeapYear(year)):
                self.checkAboveMax(day, 29)
            else:
                self.checkAboveMax(day, 28)
        self.dateString = dateString 

    def isLeapYear(self, year):
        output = False
        if(int(year)%4==0):
            if(int(year)%100!=0):
                output = True
            else:
                if(int(year)%400==0):
                    output = True

        return output

    def checkAboveMax(self, day, max):
        if(int(day)>max):
            raise ValueError(f"The day value '{day}' is above maximum value '{max}'")

    def __str__(self):
        return self.dateString
πŸ’» Code

More features

  • Todays date: Method to get todays date.
  • Date Arithmetic: Methods for plusDays, plusMonths, plusYears, minusDays, minusMonths, and minusYears.
  • Refactor the date storage from string to ints: Instead of storing “2024-07-12” we would store year: 2024, month: 7, day: 12.
  • Support for adding/subtracting weeks: plusWeeks and minusWeeks.
  • Date Comparison: Methods to compare dates, such as isBefore, isAfter, isEqual.
  • Method to check if a date is within a certain range: isWithinRange(startDate, endDate).
  • Day of the Week: Method to get the day of the week for a given date: getDayOfWeek.
  • Date Formatting: Method to format dates in different styles: formatDate(style).
  • Flexible Date Parsing: Handle various date formats, such as “2024-7-01”, “2024-07-1”, “24-07-01”, “July 1, 2024”, “1st July 2024”, and “01/07/2024”.
  • Duration Calculation: Method to calculate the duration between two dates in days, months, years.
  • etc.

There are many possibilities, the question is: “what is critical?”

TDD is a risk based testing approach!

Rapid prototyping

We all thought we knew what a calendar was, but how many people were able to specify it in the first waterfall/cowboy coding exercise?

Knowledge is not a boolean (either true or false). Knowledge is more a taxonomy, where you can ask yourself the following questions:

  1. Can you recognize it, when you see it?
  2. Can you operate it?
  3. Can you repair it?
  4. Can you build it from scratch?

Most people can recognize a helicopter.
Few people can fly it.
Fewer can repair it.
Even less can build it from scratch.

The same goes for coding. Often we have no idea how something should be coded. Sometimes we have a feeling of how it should be coded. Sometimes we have no idea where to start.

TDD lets us experience something one test at a time, so it becomes more clear with each test we make.

It will not be perfect, but often “something” is better than “nothing”.

One could call it “Rapid prototyping,” where each new test case is a tiny prototype that needs to be tested. Or as we mentioned yearlier: “Minimun viable waterfalls” πŸ˜‚πŸ˜‚πŸ˜‚

Conclusion

Let’s wrap up our journey into Test-Driven Development (TDD), shall we? πŸš€

Introduction to Writing Tests Before Code
We have learned that writing test cases before writing code requires a different perspective, viewing development as the logic connecting expected inputs with expected outputs.

TDD Cycle
We have learned the iterative cycle of TDD: writing a test case (❌ RED), writing code to pass the test (βœ… GREEN), and then refactoring the code (♻️ Refactor) to ensure all tests pass.

Comparison with Other Methods
We have learned that TDD offers a balanced approach compared to the Waterfall Method and Cowboy Coding, combining the structured planning of Waterfall with the flexibility of Cowboy Coding.

Creating a Minimum Viable Product (MVP)
We have learned that TDD focuses on creating a minimum viable product by starting with the most critical test cases and progressively adding more, ensuring the product evolves based on real requirements.

Example: Calendar Application
We have learned how to implement a calendar application using TDD, starting with simple date handling and progressively adding more complex requirements like boundary values, negative tests, and special cases like leap years.

Boundary-Value Analysis
We have learned to use boundary-value analysis to test minimum and maximum values, ensuring our application handles edge cases correctly.

Negative Testing
We have learned to extend our tests with negative cases, such as below minimum and above maximum values, to ensure the application correctly handles invalid inputs.

Extending with Exceptions
We have learned to handle special cases, like different month lengths and leap years, by extending our test cases and updating the implementation accordingly.

Critical Considerations
We have learned the importance of identifying and prioritizing critical functionalities, and considering the scope and longevity of the application when deciding which test cases to implement.

Refactoring
We have learned to refactor our code to make it more understandable and maintainable, ensuring all test cases pass after each change.

Ensuring Comprehensive Coverage
We have learned to ask critical questions about the application’s requirements and to extend our tests as necessary, ensuring comprehensive coverage of all important functionalities.

More Features
We have explored additional features, like getting today’s date, based on critical needs.
It’s a risk based testing approach.

Rapid Prototyping
We have learned that knowledge in coding is not binary; it’s a spectrum. TDD allows us to experience and refine our understanding one test at a time, making the development process clearer with each test. This approach can be seen as “Rapid Prototyping,” where each new test case is a tiny prototype that needs validation. Or, as humorously mentioned, “Minimum Viable Waterfalls” πŸ˜‚πŸ˜‚πŸ˜‚.

Congratulations – Lesson complete!