Using BDD to implement a Payments Gateway - Part 1

Using BDD to implement a Payments Gateway - Part 1
💡
This article belongs to the Walletera series.

The Walletera journey continues. In the previous article, we started working on the Dinopay Gateway. We built one of the main components, the MessagesProcessor. On the way, we learned how to work iteratively and incrementally, familiarized ourselves with the SOLID principles, and saw how to improve the type safety of our code with the use of generics. The development approach was the “traditional” one, known as test-last. We wrote the tests after writing the application code.

In this two-part article, we will continue implementing the Dinopay Gateway but take a step back and adopt a different development approach: Behavior-Driven Development (BDD). BDD is a very effective approach for aligning the software development process with business goals and user needs, increasing efficiency and productivity by reducing rework and wasted effort.

One of the distinctive aspects of BDD is that the first thing you write is not the application code but the test code. Actually, not even the test code. The development process starts with the product team (product managers, functional analysts, developers, etc.) writing a specification in natural language of how the application should behave. Then, the developers translate those specifications into executable tests. In the first part of this two-part article, we will learn the main concepts of BDD and we will also see how to write natural language specifications using a domain-specific language called Gherkin.

In the second part of this article, we will look under the hood to see how the natural language specifications are translated into executable tests. We will learn how to use some useful tools like the GoDog library that will help us bind the test code with the specifications, the testcontainers library, which streamlines the management of Docker containers from Go code, and also Mock-Server, which will allow us to create a mock server for the DinoPay HTTP API.

That's a lot, so we better crack on!

Behavior-Driven Development

From BDD 101: A Comprehensive Guide to Behavior-Driven Development

Behavior-driven development, or BDD, helps business and technical teams better communicate to avoid waste (rework caused by vague requirements, slow feedback cycles, and other problems that commonly arise in non-BDD scenarios). Using examples written in a domain-specific language, or DSL, BDD enables non-technical users to express software behavior and expected outcomes. Technical users convert these examples into executable specifications that help ensure that their code meets business objectives outlined in user stories and acceptance criteria.

Here, we have one of the main ideas of BDD: the software behavior and expected outcome are specified using examples by non-technical team members (stakeholders, functional analysts, product managers, etc.). As we will see later, these examples, which are written in a domain-specific language, will be the tests for our application. Then, the technical team members implement those tests and use them to drive the development of the application. This way, BDD ensures that the code the engineers write meets the business requirements specified by the product team.

Behavior-driven development is a two-part process consisting of a deliberate discovery phase and a testing phase. The deliberate discovery phase brings together key stakeholders (product owners, business analysts, domain experts, users, and developers) to have conversations and come up with concrete examples of user stories and acceptance criteria. These examples ultimately become the automated tests and living documentation showing how the software behaves. In the testing phase, we translate the concrete examples written in natural language into executable tests and write the code necessary to make those tests pass.

Cool, we know what BDD is, so it's time to start working on our DinoPay Gateway. In the next section, we will discuss the user stories and acceptance criteria for our service and will come up with some concrete examples. This is phase number one of BDD, the deliberate discovery phase. We will describe the testing phase in the second part of this article. There, we will use GoDog, testcontainers, and Mock-Server to transform those concrete examples into executable tests. In the end, we will complete the development process, implementing the service code to make the tests pass.

Deliberate Discovery Phase

According to BDD, the deliberate discovery phase starts after the team writes the user stories and the acceptance criteria. In the first article of our series, we wrote two requirements for our digital wallet: DinoPay Deposits and DinoPay Withdrawals. In this article, we will see how to apply BDD to implement one of those requirements: DinoPay Withdrawals.

DinoPay Withdrawals: User Story and Acceptance Criteria

To refresh our memories (or in case you didn’t read the previous article), I rewrote below the DinoPay Withdrawals requirement into a more compact user story form.

As a Walletera user with funds on my Walletera account, I want to withdraw, partially or totally, those funds to my DinoPay account so I can use them to pay with DinoPay.

The acceptance criteria for the happy path of this requirement is the following.

A happy path scenario is one in which, given valid input and a healthy context, the software under testing successfully processes the data and produces the expected outcome.
Given a user with funds in her Walletera account
And   the user has created a DinoPay Beneficiary
When  the user creates a Withdrawal for that beneficiary
Then  the withdrawal amount must be debited from the user’s funds
 And  a Payment must be created on DinoPay with the Beneficiary Account as the Destination Account

This is a scenario-based acceptance criteria. In this case, the acceptance criteria describe a happy path scenario where the user successfully withdraws money. As you can see, it complements the user story by adding pre-conditions (the existence of a DinoPay Beneficiary) and post-conditions (a Payment successfully created on DinoPay).

In addition to the happy path scenario, we must also consider the different situations where a withdrawal can fail. These situations are known as unhappy path scenarios.

A note on end-to-end testing

The acceptance criteria written in the previous section describe the complete workflow of the withdrawal happy path scenario. Now, if you remember from the first article, specifically from the architecture section, you will know that the chosen architecture is microservices. This means that implementing the withdrawal user story will involve more than one service. It will involve the Payments Service and the DinoPay-Gateway Service. A test covering all the services involved in this feature would be an end-to-end test. Implementing that kind of test on a microservices architecture is a very complex task. Actually, there are people who consider it a bad practice and discourage it. For example, you can read this article or watch this video.

Because we don't want to write an end-to-end test for the whole platform, in the next section, we will scope the requirement, and in particular, the acceptance criteria, to the DinoPay Gateway context. That way, the tests that we will write as part of the BDD process will be a more focused type of test known as service tests.

Refining the DinoPay Withdrawal acceptance criteria

Before writing the new, refined acceptance criteria, let's refresh our memories with the architecture diagram of our platform.

From the diagram, we can see that the DinoPay Gateway has the following inputs and outputs:

Inputs:

  • Domain events of type WithdrawalCreated

  • Webhook notification of type payment.updated

Outputs:

  • POST request to the /payments endpoint of the DinoPay API

  • PATCH request to the /withdrawals/{withdrawalId} endpoint of the Payments Service API

To keep both the article and the implementation short, we will only consider as input the WithdrawalCreated domain event and, as output, the POST request to the dinopay gateway.

Let's now write the acceptance criteria for the withdrawal requirement happy path scenario of the DinoPay Gateway service.

Given a withdrawal created event
When  the event is published
Then  the dinopay-gateway successfully creates a payment on DinoPay

Great! We refined our acceptance criteria and made it specific to our service. We can proceed now with the next and final step of the deliberate discovery phase, writing concrete examples.

Concrete examples written as Gherkin scenarios

We will use a domain-specific language called Gherkin to write a concrete example of the DinoPay Withdrawal acceptance criteria. Gherkin's main benefit is that it allows non-technical stakeholders (product managers, functional analysts, etc.) to write executable specifications in plain text.

Gherkin specifications consist of multiple examples or scenarios written in a plain text file called feature file. These files are written using a set of special keywords that give structure and meaning to the executable specifications.

In this section, we will learn the main Gherkin keywords and how to use them to write a concrete example for the DinoPay Withdrawal acceptance criteria.

We will start with an empty file called withdrawal_created.feature . The first line we will write is the following.

Feature: process WithdrawalCreated event

The Feature keyword is the first primary keyword in a Gherkin document. It provides a high-level description of a software feature and groups related scenarios.

Then we will start the scenario with the Scenario keyword followed by a free text description.

Scenario: withdrawal created event is processed successfully

And now comes the interesting part, the steps.

The acceptance criteria first step is the following: Given a withdrawal created event. To make this step concrete, we need a concrete example of an WithdrawalCreated event. Fortunately we already wrote one in the first article of the series. Let's copy it and write the Gherkin step.

Given a withdrawal created event:
    """json
    {
      "type": "WithdrawalCreated",
      "data": {
         "id": "0ae1733e-7538-4908-b90a-5721670cb093",
         "user_id": "2432318c-4ff3-4ac0-b734-9b61779e2e46",
         "psp_id": "dinopay",
         "external_id": null,
         "amount": 100,
         "currency": "USD",
         "status": "pending",
         "beneficiary": {
           "id": "2f98dbe7-72ab-4167-9be5-ecd3608b55e4",
           "description": "Richard Roe DinoPay account",
           "account": {
            "holder": "Richard Roe",
            "number": 1200079635
           }
         }
      }
    }
    """

To write this step, we used the Gherkin Given keyword. The steps starting with this keyword are used to describe the initial context of the system, i.e., to put the system in a known state before the user (or external system) starts interacting with it.

In our case, the Given step describes the existence of an WithdrawalCreated event. Following the step, we can see a json object with the concrete example of the WithdrawalCreated event copied from the first article of the series. This is a step argument, and, as we will see later, it will be passed as an argument to the step function that implements this step.

Before writing the When step, we need another Given step. This step was not present in the acceptance criteria but it is needed to translate this concrete example into an executable specification. In particular, this step will allow us to verify one of the outputs of the scenario, which is the creation of the payment on the DinoPay API.

The extra Given step looks like this (to improve the readability of the scenario, we use the And keyword, which is a synonym of Given).

    And  a dinopay endpoint to create payments:
    # the json below is a mockserver expectation
    """json
    {
      "id": "createPaymentSucceed",
      "httpRequest" : {
        "method": "POST",
        "path" : "/payments",
        "body": {
            "type": "JSON",
            "json": {
              "customerTransactionId": "0ae1733e-7538-4908-b90a-5721670cb093",
              "amount": 100,
              "currency": "USD",
              "destinationAccount": {
                "accountHolder": "Richard Roe",
                "accountNumber": "1200079635"
              }
            },
            "matchType": "ONLY_MATCHING_FIELDS"
        }
      },
      "httpResponse" : {
        "statusCode" : 201,
        "headers" : {
          "content-type" : [ "application/json" ]
        },
        "body" : {
          "id" : "bb17667e-daac-41f6-ada3-2c22f24caf22",
          "amount" : 100,
          "currency" : "USD",
          "sourceAccount" : {
            "accountHolder" : "john doe",
            "accountNumber" : "IE12BOFI90000112345678"
          },
          "destinationAccount" : {
            "accountHolder" : "jane doe",
            "accountNumber" : "IE12BOFI90000112349876"
          },
          "status" : "pending",
          "customerTransactionId" : "9713ec22-cf8d-4a21-affb-719db00d7388",
          "createdAt" : "2023-07-07",
          "updatedAt" : "2023-07-07"
        }
      },
      "priority" : 0,
      "timeToLive" : {
        "unlimited" : true
      },
      "times" : {
        "unlimited" : true
      }
    }
    """

What we are specifying here are the details of the request the dinopay-gateway must send to the DinoPay API to create a payment. To do that, we use a mock-server expectation. We will see later what exactly a mock-server expectation is, but for now, it is sufficient to know that this expectation will allow us to verify that the request sent by the dinopay-gateway to the DinoPay API is correct, i.e., it contains the correct path, the correct HTTP method, and the correct body attributes.

So far, we have configured the initial state of our scenario. The next step in the acceptance criteria is When the event is published . In this case, the Gherkin keyword that we use is When . This keyword is used to describe the event or action that triggers the scenario. Let’s see what it looks like.

When the event is published

In our case, this step represents the action of publishing the WithdrawalCreated event that we wrote in the first step.

The final part of the acceptance criteria says Then the dinopay-gateway successfully creates a payment on DinoPay . This is the expected outcome for this particular scenario. Again, the transition into Gherkin is trivial because. We just need to use the Then keyword.

Then the dinopay-gateway creates the corresponding payment on the DinoPay API

We decided to include an extra step. This step states that we want a specific log line written by the dinopay-gateway as part of the expected output. It looks like this.

    And  the dinopay-gateway produces the following log:
    """
    WithdrawalCreated event processed successfully
    """

Specifying a specific log line as part of the software outcome may sound a bit weird, but let me tell you that it is not because the log is part of the product that we are specifying here. If you want to dig more into the importance of a product log, you can read this great article.

Below is the complete withdrawal_created.feature file.

Feature: process WithdrawalCreated event

  Scenario: withdrawal created event is processed successfully
    Given a withdrawal created event:
    """json
    {
      "type": "WithdrawalCreated",
      "data": {
         "id": "0ae1733e-7538-4908-b90a-5721670cb093",
         "user_id": "2432318c-4ff3-4ac0-b734-9b61779e2e46",
         "psp_id": "dinopay",
         "external_id": null,
         "amount": 100,
         "currency": "USD",
         "status": "pending",
         "beneficiary": {
           "id": "2f98dbe7-72ab-4167-9be5-ecd3608b55e4",
           "description": "Richard Roe DinoPay account",
           "account": {
            "holder": "Richard Roe",
            "number": 1200079635
           }
         }
      }
    }
    """
    And  a dinopay endpoint to create payments:
    # the json below is a mockserver expectation
    """json
    {
      "id": "createPaymentSucceed",
      "httpRequest" : {
        "method": "POST",
        "path" : "/payments",
        "body": {
            "type": "JSON",
            "json": {
              "customerTransactionId": "0ae1733e-7538-4908-b90a-5721670cb093",
              "amount": 100,
              "currency": "USD",
              "destinationAccount": {
                "accountHolder": "Richard Roe",
                "accountNumber": "1200079635"
              }
            },
            "matchType": "ONLY_MATCHING_FIELDS"
        }
      },
      "httpResponse" : {
        "statusCode" : 201,
        "headers" : {
          "content-type" : [ "application/json" ]
        },
        "body" : {
          "id" : "bb17667e-daac-41f6-ada3-2c22f24caf22",
          "amount" : 100,
          "currency" : "USD",
          "sourceAccount" : {
            "accountHolder" : "john doe",
            "accountNumber" : "IE12BOFI90000112345678"
          },
          "destinationAccount" : {
            "accountHolder" : "jane doe",
            "accountNumber" : "IE12BOFI90000112349876"
          },
          "status" : "pending",
          "customerTransactionId" : "9713ec22-cf8d-4a21-affb-719db00d7388",
          "createdAt" : "2023-07-07",
          "updatedAt" : "2023-07-07"
        }
      },
      "priority" : 0,
      "timeToLive" : {
        "unlimited" : true
      },
      "times" : {
        "unlimited" : true
      }
    }
    """
    When the event is published
    Then the dinopay-gateway creates the corresponding payment on the DinoPay API
    And  the dinopay-gateway produces the following log:
    """
    WithdrawalCreated event processed successfully
    """

Wrapping Up

With the concrete example (scenario) written in a Gherkin feature file, we reached the end of the BDD Discovery Phase. In the next part of this article, we will address the Testing Phase. This involves writing the Go code that will allow the scenario to run as an executable test and implementing the application code to make the test pass. See you there!