BDD Framework: Crafting Software to Fit Business Objectives

Fahmi Rahmadani
Life at Telkomsel
Published in
10 min readJun 27, 2024

In today’s dynamic software development environment, ensuring that software aligns with business goals is crucial. Behavior Driven Development (BDD) offers a practical framework to achieve this alignment by ensuring the software’s behavior meets business needs. This article explores how BDD helps create software that not only functions correctly but also delivers value by addressing the right problems.

Understanding the Basics of Testing

Modern software development involves various types of testing to ensure that the software we deliver meets a certain level of standard. We’re already familiar with the concept of Testing Pyramid, where tests are categorized based on what scope it usually tests, and the cost of running them.

Source: sketchingdev.co.uk
  • Unit Tests: Test individual components like functions or classes to ensure they work correctly. These tests are quick to run and usually are aimed to achieve high coverage.
  • Integration Tests: Verify that different components, such as APIs and databases, interact correctly.
  • End-to-End (E2E) Tests: Simulate user scenarios to test the overall system’s functionality.

These tests ensure that the software is technically sound and functions as expected, which is about making the software right. However, BDD takes a broader approach by also ensuring that the software is the right software — one that effectively meets business goals and user needs.

What is BDD?

Behavior Driven Development (BDD) is a methodology that describes software behavior in natural language, making it accessible and understandable for both technical and non-technical stakeholders. This approach bridges the gap between different teams and ensures that everyone has a shared understanding of what the software should achieve.

Key Components of BDD:

  • Context (Given): Describes the initial situation or setup.
  • Action (When): Specifies the action or event that triggers the behavior.
  • Outcome (Then): Defines the expected result or outcome.

BDD emphasizes understanding and testing software behavior to ensure it not only functions correctly but also aligns with business objectives, thus addressing both making the software right and making the right software.

It encourages early and ongoing conversations and collaboration between engineers, project managers (PMs), quality assurance (QAs), and business users. This collaboration ensures that software behavior aligns with business goals, reducing misunderstandings and aligning expectations across all stakeholders.

To give you an initial idea, here’s an example of BDD test scenarios. You don’t need to be a highly technical person to understand what the feature is all about and the expected system behavior in different scenarios. Later, we will use this example scenario to demonstrate how to test the software we deliver using BDD.

Feature: Employee Clock-In
Employees should be able to clock in their arrival status based on their working location.

Scenario: Clock in for WFO employee
Given the employee is at "the office"
When the employee clocks in
Then the clock-in status should be "approved"

Scenario: Clock in for WFH employee
Given the employee is at "home"
When the employee clocks in
Then the clock-in status should be "pending for approval"
And the superior should be notified to approve the request

TDD vs. BDD: Key Differences

Source: https://www.linkedin.com/pulse/what-difference-between-test-driven-development-tdd-bdd-chatterjee

The key difference lies in their approach: TDD focuses on the internal workings and correctness of the code, while BDD ensures that the software’s behavior aligns with business objectives and user needs. This helps prevent the common pitfall of building a technically correct system that ultimately fails to meet business goals.

The Benefits of BDD Feature Testing

BDD feature testing offers several advantages:

  • Clear Requirements: BDD scenarios serve as easily understandable documentation, ensuring that software addresses the correct problem.
  • Unified Understanding: Facilitates a shared understanding of the software’s behavior among all stakeholders.
  • Prevents Scope Creep: Clearly defined scenarios keep the focus on delivering intended business objectives.
  • Effective Regression Testing: Helps identify issues when new features are added, maintaining software integrity over time.

Many software projects fail to meet their goals, leading to wasted resources and unmet business needs. BDD helps ensure that software development stays aligned with business objectives, avoiding common pitfalls.

Typical Sprint with BDD

Source: https://www.slideshare.net/slideshow/behaviour-driven-development-bdd-closing-the-loop-on-a-great-fiori-ux/74927727

This image illustrates the BDD development process in a typical sprint. It begins with a conversation between the product owner and users to discuss their needs and expectations. The product owner, developer, and tester then collaboratively refine these requirements. They transform the requirements into structured scenarios, using clear, natural language to describe the desired software behavior. These scenarios guide the development process and serve as automated tests. Finally, the tester uses these scenarios to validate the software, ensuring it meets the specified requirements and delivers the intended value.

Implementing BDD with Go and Testcontainers

Testcontainers:

Testcontainers is an open-source framework designed for automating integration tests with container-based dependencies. It enables the setup of various services such as databases, caches, and message brokers directly within the test environment, allowing tests to interact with real dependencies instead of mocks. This approach ensures more accurate and reliable testing outcomes.

Testcontainers currently supports a large number of preconfigured modules, such as relational databases, NoSQL databases, vector databases, message brokers, caches, and load balancers. This broad support increases the likelihood that the dependencies you need are officially supported. If not, Testcontainers can also spin up a container using an image from Docker Hub or another image repository. It is compatible with almost all popular programming languages, including Go, NodeJS, Java, Python, Ruby, and Rust.

Testcontainer’s working principle. Source: https://testcontainers.com/getting-started/

For instance, if our end-to-end testing requires a MySQL database and a Redis cache, we can configure these dependencies programmatically to run our tests as follows:

func startMySqlContainer(ctx context.Context, opts ...mySqlContainerOption) (*mySqlContainer, error) {
req := testcontainers.ContainerRequest{
Image: "mysql:8.0.36",
Env: map[string]string{},
ExposedPorts: []string{},
}

for _, opt := range opts {
opt(&req)
}

container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
log.Fatalf("Failed to start container: %s", err)
return nil, err
}

return &mySqlContainer{Container: container}, nil
}

Similarly, for Redis, we can configure the necessary settings:

func startRedisContainer(ctx context.Context) (*redisContainer, error) {
req := testcontainers.ContainerRequest{
Image: "redis:7.2.4",
ExposedPorts: []string{"6379/tcp"},
WaitingFor: wait.ForLog("Ready to accept connections"),
}

redisC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
log.Fatalf("Failed to start redis container: %v", err)
}

return &redisContainer{Container: redisC}, nil
}

In our BDD Godog test script, we can start MySQL and Redis containers by invoking the Testcontainers library within our regular Go testing script, whenever required. This integration allows us to dynamically spin up and manage these dependencies as part of our testing process.

func TestScenarios(t *testing.T) {
ctx := context.Background()
port, _ := nat.NewPort("tcp", "3306")

const dbName = "dbName"
const user = "root"
const password = "password"

container, err := startMySqlContainer(ctx,
WithPort(port.Port()),
WithInitialDatabase(user, password, dbName),
WithWaitStrategy(wait.ForLog("port: 3306 MySQL Community Server - GPL")),
WithSqlScripts("./db/migrations"),
)
if err != nil {
t.Fatal(err)
}

host, err := container.Host(ctx)
containerPort, _ := container.MappedPort(ctx, port)

if err != nil {
fmt.Println("Error getting host")
}

redisC, err := startRedisContainer(ctx);
if err != nil {
t.Fatal(err)
}
// ... the rest of test setup
}

Setting Up BDD with Godog:

Godog is the official BDD framework for Go, supporting the Gherkin syntax for writing clear and concise scenarios. Since BDD emphasizes accessibility for a broad range of stakeholders, Godog supports over 80 languages, including Bahasa Indonesia, Javanese, and even Pirate language (Arr!). This extensive language support makes it easier to write feature scenarios that are approachable and understandable for a diverse audience.

Now, let’s move to a step-by-step implementation to give you an idea of how to implement BDD with Godog and Testcontainers.

Steps for Implementation

  1. Write Gherkin Feature Files: Outline the desired behavior using natural language in these files.
  2. Implement Step Definitions in Go: Each step in the Gherkin file corresponds to a function in Go that performs the necessary actions and checks.
  3. Run Tests with Go’s Native Tools: Execute BDD tests using Go’s native testing tools to verify the software’s behavior.

Example Implementation

Here’s a simple example of BDD feature testing for a clock-in system within an internal employee app. This test demonstrates how the system behaves differently for employees working from the office (WFO) compared to those working from home (WFH).

  1. Define a Feature File:
Feature: Employee Clock-In
Employees should be able to clock in their arrival status based on their working location.

Scenario: Clock in for WFO employee
Given the employee is at "the office"
When the employee clocks in
Then the clock-in status should be "approved"

Scenario: Clock in for WFH employee
Given the employee is at "home"
When the employee clocks in
Then the clock-in status should be "pending for approval"
And the superior should be notified to approve the request

It’s important to note that both scenarios share similar steps. The step “Given the employee is at” is reused, differing only in the specified location, such as “the office” or “home.” This flexibility allows the same step to be utilized multiple times with various location values (e.g., “customer’s office”, “out of country”) without the need to write separate Go backing functions for each scenario.

2. Pair the steps with Go function:

func InitializeScenario(ctx *godog.ScenarioContext, app *fiber.App) {
api := &apiFeature{
app: app
}

ctx.Given(`the employee is at "([^"]*)"$`, api.theEmployeeIsAt)
ctx.When(`the employee clocks in`, api.theEmployeeClocksIn)
ctx.Then(`the clock-in status should be "([^"]*)"$`, api.theClockInStatusShouldbe)
ctx.And(`the superior should be notified to approve the request`, api.theSuperiorShouldbeNotifiedToApproveTheRequest)
}

In this function, we use regular expressions (regex) to match the statements in the Feature File and link them to the corresponding Go backing functions. We can also capture variables for our functions by matching the regex pattern "([^"]*)"$, which translates to any character sequence enclosed in quotes.

3. Implement the Steps in Go:

  • The “Given” implementation:
// Given step for determining employee's location
func (a *apiFeature) theEmployeeIsAt(ctx context.Context, location string) (context.Context, error) {
var gpsCoordinates string

switch location {
case "the office":
gpsCoordinates = "-6.172410, 106.827153" // simulate GPS coordinate for office
case "home":
gpsCoordinates = "-6.2284, 106.8852" // simulate random GPS coordinate for home
}
return context.WithValue(ctx, godogResponseCtxKey{}, gpsCoordinates), nil
}

For simplicity, let’s assume the system differentiate between WFH and WFO by using GPS coordinates. In “Given” step, we set the respective mocked coordinates based on the scenarios.

  • The “When” implementation
// "When" step for the clock in action
func theEmployeeClocksIn(ctx context.Context) (context.Context, error) {

// Retrieve the GPS coordinate from previous step
gpsCoordinates, ok := ctx.Value(godogResponseCtxKey{}).(string)
if !ok {
fmt.Println("Payload is not a string: ", gpsCoordinates)
}

// Prepare the payload to hit the API
payload := model.ClockInReq{
employeeNik: "12345",
coord: gpsCoordinates
}
reqBody, err := json.Marshal(payload)
req := httptest.NewRequest("POST", path, bytes.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-cache-control", "no-cache")

// Send the request to the API
res, err := a.app.Test(req)
if err != nil {
fmt.Printf("Error sending request: %v\n", err)
}
body, _ := io.ReadAll(res.Body)

// Pass the response from API to the next step to be verified
return context.WithValue(ctx, godogResponseCtxKey{}, body), nil
}

We take the mocked coordinates from the previous step and use them to prepare the required payload format to interact with our Clock In API. The response is then passed on to the subsequent step for further processing.

  • The “Then” and “And” Implementation
// Then step for WFO clock-in approval
func theClockInStatusShouldBe(ctx context.Context, status string) (context.Context, error) {
// Receive the response from previous step
body, ok := ctx.Value(godogResponseCtxKey{}).([]byte)
if !ok {
fmt.Println("Response body is not a byteslice: ", body)
}
resp := responseSuccess{
Data: model.ClockInRes{},
Meta: model.Meta{},
}
if err := json.Unmarshal(body, &resp); err != nil {
fmt.Println("Error unmarshalling response body: ", err)
}

// Check if response status is as expected
if resp.Data.Status != status {
return fmt.Errorf("expected status %s, got %s", status, resp.Data.Status)
}

// Return err=nil to indicate the test has been PASSED, and data for any subsequent step
return context.WithValue(ctx, godogResponseCtxKey{}, resp.Data, nil
}

// And step for notifying the superior
func theSuperiorShouldBeNotifiedToApproveTheRequest(ctx context.Context) error {
resData, ok := ctx.Value(godogResponseCtxKey{}).([]byte)
if !ok {
fmt.Println("Response body is not a byteslice: ", body)
}
clockInData := model.ClockInRes{}
if err := json.Unmarshal(resData, &clockInData); err != nil {
fmt.Println("Error unmarshalling clock in res data: ", err)
}

// Assume we can access superior notification list by helper function
notif, err := helper.FetchLastNotif(data.superiorId)
// If the correct notif does not reach the superior, return error
if clockInData.Id != notif.ClockInId {
return fmt.Error("Notification does not reach the superior")
}

return nil
}

In this step, we check the response obtained from the previous step and verify that its content matches our expectations. The expected status is extracted from the Feature File using regex. For the “the office” scenario, the test expects the status to be “approved”. Conversely, if the location is “home”, the status should be “pending for approval”.

Conclusion

By adopting BDD with tools like Godog and Testcontainers, you can ensure that your software meets both technical requirements and business objectives. BDD enhances communication and collaboration, aligning software behavior with business goals.

Testcontainers allows for easy setup of real dependencies such as databases and caches, improving test reliability. With support for preconfigured modules, including various types of databases and message brokers, it simplifies the configuration of essential services.

Godog’s multi-language support makes feature scenarios accessible to diverse stakeholders, fostering understanding and alignment. By integrating BDD, you create software that not only functions correctly but also aligns and supports business goals effectively, leading to more successful and valuable projects.

References

¹ BDD in Action: Behavior-Driven Development for the whole software lifecycle. Chris Kanaracus, “Air Force scraps massive ERP project after racking up $1 billion in costs,” CIO, November 14, 2012, http://www.cio.com/article/2390341

² Integration Tests with Godog and Testcontainers — Rafet Ongun. https://www.youtube.com/watch?v=12kIIvu-rno&t=22s

³ Scott Ambler, Surveys Exploring the Current State of Information Technology Practices, http://www.ambysoft.com/surveys

--

--