In the fast-paced world of software development we live in today, it’s always a challenge to deliver top-notch applications quickly and efficiently. Thorough testing is an essential part of the development process to achieve that. By combining Behaviour-Driven Development (BDD) with Test-Driven Development (TDD), we can enhance the effectiveness of the testing process and ultimately improve the overall quality of our software.

In this blog , we will dive into the world of BDD and TDD, exploring their fundamental principles, benefits, and practical implementation techniques

Behaviour Driven Development

BDD is an agile software development methodology that focuses on aligning business stakeholders, developers, and testers around a shared understanding of the system’s behaviour and requirements.

In BDD, the emphasis is on defining and describing the expected behaviour of the system in a human-readable format. This is generally done through the creation of structured scenarios written in a Given-When-Then format. These scenarios describe the preconditions (Given), the actions or events (When), and the expected outcomes (Then) of a particular feature or behaviour.

Test Driven Development

TDD is a software development practice that prioritises writing tests before the actual code. It involves initially writing tests that are expected to fail, followed by writing the minimum amount of code required to pass those tests. Once the code passes the tests, the focus shifts to refactoring it for improved efficiency, design and cleanliness.

Phases of TDD

  1. RED: In this phase, you write the failing test cases that represents the desired behaviour or functionality you want to implement.

  2. Green: Once you have the failing tests, quickly write the minimum amount of code required to make the tests pass.

  3. Refactor: After the tests pass, our aim is to improve the code without changing its behaviour. This phase focuses on enhancing the code’s design, readability, maintainability and performance. You can restructure the code, apply the appropriate design pattern, extract methods, eliminate duplication, and make it more efficient or easier to understand.

Advantages of TDD

Fast Feedback Loops: TDD enables rapid feedback loop between development and testing. Developers can run tests frequently, ideally after each code change, to ensure that everything is functioning as expected. This immediate feedback enables quick identification and resolution of issues, resulting in faster development cycles.

Improved Code Quality: TDD promotes writing high-quality code. By writing tests first, developers are forced to think about the requirements first. Thus gaining a clearer understanding of the desired functionality and can design code that meets the requirements. This leads to cleaner, more maintainable, and less error-prone code.

Gamification of Development: After writing extensive test cases in the red phase, the given problem can now be treated as a competitive programming problem where you need to pass the given test cases in the most efficient way. Thus coding now becomes more fun!

Increased Test Coverage: Since test cases for each feature are written before the code, this ensures a higher rate of test coverage. It encourages developer to write all possible test cases, leading to more comprehensive test suites.

Enhanced Design and Architecture: Kent Beck author of TDD by Example points out we can’t do 2 things at once easily, understand the solution to the problem and engineer the code right. We could end up over engineer the solution beyond what the test requires or get into analysis paralysis. In TDD, developers focus on the expected behaviour and design their code accordingly. This leads to more modular, loosely coupled, and easily extensible code, promoting good software architecture.

Refactoring Safety and Greater Code Confidence: TDD provides developers with a safety net when making changes to existing code. Since the codebase is well-covered with tests, developers can refactor or modify code with confidence, knowing that the tests will quickly catch unintended consequences.

Better documentation and collaboration: TDD promotes collaboration between developers and testers. Tests can serve as documentation that define the expected behaviour, developers and testers can have a common understanding of the requirements.

Limitations of TDD:-

Time-consuming and Initial Learning Curve: TDD can be time-consuming, especially in the beginning, as it requires writing tests before writing the actual code. This extra effort may extend the development time initially. However, proponents argue that the time saved from debugging and maintenance in the long run can offset this initial investment.

False sense of security: While TDD helps catch many bugs early, it does not guarantee a bug-free application. There may still be edge cases that are not adequately covered by the tests. Developers must be cautious not to rely solely on the existing test suite and should perform additional quality assurance measures to ensure overall product quality.

BDD with TDD: The behavioural specifications defined in BDD scenarios guide the creation of test cases in TDD. The development follows a Red-Green-Refactor cycle, where failing test cases drive the implementation of code and continuous refactoring increases the code quality.

The objective of BDD/TDD is to test the behaviour of the system, not just the methods of individual classes. Rather than solely aiming to increase test coverage, we should shift our focus to the specific behaviours these tests are intended to validate.

Example for BDD/TDD in Go:

Consider a simple example where we need to calculate the employee payout given the parameters hourly pay rate, no of hours worked, commissions, no. of projects and employer cost.

BDD Phase

We create a list of all possible expected behaviours of the software in Given-When-Then format. Using a table where we represent each variable that can change with a column. The last row represents the final expected output. We then confirm the behaviour from business stakeholders.

Given, Hourly Rate to be 10$ and Employer Cost to be 100$ for each employee. Commission is given for each project. The output (Payout) is expected in decimal format.

table.png

RED Phase

Here we create the Employee struct with a function to compute the payout. We then create a test file which contains a test for each behaviour in the above table. Now we run the tests to see them fail, thus completing our red phase.

type Employee struct {
	Name          string
	Id            int
	HourlyPayRate float32 `default:"10"`
	HoursWorked   int
	EmployerCost  float32 `default:"100"`
	HasCommision  bool
	Commision     float32
	NoOfProjects  int
}

func (Employee) ComputePayout() (payout float32) {
	panic("Compute payout func not implemented")
}
func (suite *EmployeeComputePayrollTestSuite) Test_EmployeePayout_Returns_Float() {
	assert.Equal(suite.T(), reflect.TypeOf(suite.Employee.ComputePayout()).String(), "float32")
}

func (suite *EmployeeComputePayrollTestSuite) Test_EmployeePayout_NoCommission_NoHours() {
	assert.Equal(suite.T(), suite.Employee.ComputePayout(), float32(100))
}

func (suite *EmployeeComputePayrollTestSuite) Test_EmployeePayout_NoCommission() {
	suite.Employee.HoursWorked = 10
	assert.Equal(suite.T(), suite.Employee.ComputePayout(), float32(200))
}

func (suite *EmployeeComputePayrollTestSuite) Test_EmployeePayout_WithCommission() {
	suite.Employee.HoursWorked = 10
	suite.Employee.HasCommision = true
	suite.Employee.Commision = 10
	suite.Employee.NoOfProjects = 10
	assert.Equal(suite.T(), suite.Employee.ComputePayout(), float32(300))
}

func (suite *EmployeeComputePayrollTestSuite) Test_EmployeePayout_Commission_Disabled() {
	suite.Employee.HoursWorked = 10
	suite.Employee.HasCommision = false
	suite.Employee.Commision = 10
	assert.Equal(suite.T(), suite.Employee.ComputePayout(), float32(200))
}

red.png

GREEN Phase

Here we create the Employee struct with a function to compute the payout. We then create a test file which contains a test for each behaviour in the above table. Now we run the tests to see them fail, thus completing our red phase.

In this stage, our primary objective is to expedite the successful execution of all the tests by quickly implementing the necessary logic.

func (emp Employee) ComputePayout() float32 {
	payout := emp.HourlyPayRate * float32(emp.HoursWorked)
	payout = payout + emp.EmployerCost
	if emp.HasCommision {
		payout = payout + (emp.Commision * float32(emp.NoOfProjects))
	}
	return payout
}

green.png

REFACTOR Phase

This phase focuses on the generation of clean code by removing duplication, applying the appropriate design patterns, formatting long methods, removing code smells, etc.

In this phase, you can ensure that your code conforms to the specification by running tests. This allows you to modify the code and verify that it still functions correctly. For example, you might decide that the term ‘employer cost’ is too broad, and instead, you want to separate it into ‘employer office cost’ and ‘employer support cost’ with default values of $60 and $40, respectively. As a result, the final refactored method would be:

func (emp Employee) ComputePayout() (payout float32) {
	payout = (emp.HourlyPayRate * float32(emp.HoursWorked)) + emp.EmployerOfficeCost + emp.EmployerSupportCost
	if emp.HasCommision {
		payout += emp.Commision * float32(emp.NoOfProjects)
	}
	return
}

Conclusion

Adopting Behaviour-Driven Development (BDD) with Test-Driven Development (TDD) can significantly elevate both code quality and development speed. By focusing on the desired behaviours of the system rather than just individual methods, BDD/TDD encourages a more comprehensive and efficient testing approach. Embracing BDD/TDD enables teams to deliver functionality to customers faster while maintaining a high level of code quality, avoiding potential long-term setbacks caused by neglecting tests.