8

TDD, BDD, and DDD

We have now covered all the core concepts of domain-driven design (DDD). However, you will often see the suite of acronyms that make up this chapter’s title in the same sentence, especially on job postings and résumés. What do they mean? Are they related to DDD?

In this chapter, we will do the following:

  • Discuss and give examples of TDD and BDD using Go
  • Talk about how TDD and BDD can be used alongside DDD to make your systems more resilient and maintainable

Technical requirements

In this chapter, we will write a large amount of Golang code. To be able to follow along, you will need the following:

TDD

TDD stands for test-driven development. It is a process in which you write tests for business requirements before your software is fully developed. As you write code, you repeatedly update your test cases until you are satisfied the code satisfies the business requirements. The goal is to write “just enough” code to pass the tests and no more. A diagram representing this process is shown here:

Figure 8.1 – TDD flow chart

Figure 8.1 – TDD flow chart

Let’s look at each of the steps in isolation. If we were developing a new feature for our application, we would do the following:

  1. Add a test: Before we write any code, we write the test case. You might write this in the form of a user story such as “As an API user, I want to be able to see a user’s balance across all their accounts when I call the /balances endpoint so that I can display it on the home screen,” or by using the Given-When-Then method: “Given I am an API user, when I call /balances, I get a user’s balances across all accounts.”

As you can see, we have not written a single line of code yet, and we are focusing deeply on the business requirements. This should hopefully highlight immediately why TDD and DDD are complementary patterns.

  1. Run the test we just wrote. It should fail (and we should expect it to): This proves that the expected behavior isn’t already available in our code, that our testing framework is set up correctly, and rules out the possibility that we wrote a flawed test that is always going to pass.
  2. Write as little code as possible to pass the test: This is not the time to write elegant code. At this stage, spaghetti code or confusing, inefficient code is welcome. The goal is to get that test case passing by any means necessary while solving for the business invariant.
  3. Rerun the tests – the new one and all the previous ones should now pass: This validates that the code we have written not only solves the new behavior but also didn’t break existing behavior.
  4. Refactor: Now that we have added our new feature, it’s time to revisit the spaghetti code we wrote to pass the test and make it beautiful and ready for code review. With each change we make, we can rerun the test suite to ensure our refactor did not change behavior.

That’s all there is to TDD. It can also be used to debug or improve legacy code. For example, you could write tests to give yourself confidence that the code works as you expect and then refactor it to ensure the tests still pass.

Now that we understand TDD in principle, let’s imagine we were given this ticket to complete:

Title: As a customer, when I purchase a cookie, I get an email receipt.

Description: Customers like to purchase cookies. They also like to claim them as a business expense. We need to add support for purchasing a cookie and sending an email receipt to a customer.

This is the acceptance criteria:

  • Given that a user tries to purchase a cookie and we have them in stock when they tap their card, they get charged and then receive an email receipt a few moments later
  • Given that a user tries to purchase a cookie and we don’t have any in stock, we return an error to the cashier so they can apologize to the customer
  • Given that a user tries to purchase a cookie and we have them in stock, but their card gets declined, we return an error to the cashier so that we can ban the customer from the store
  • Given that a user purchases a cookie and we have them in stock, their card is charged successfully, but we fail to send an email, we return a message to the cashier so they can notify the customer that they will not get an email, but the transaction is still considered complete

This ticket describes the expected user behaviors for us. Let’s look at how we might follow TDD step by step to complete this task in Go.

Adding a test

Let’s make a new file called cookies_test.go, as well as cookies.go. Your directory should now look as follows:

Figure 8.2 – Our directory structure after making our new files

Figure 8.2 – Our directory structure after making our new files

Note how my IDE has detected that cookies_test.go is a test file and has highlighted it in green. This is because, in Go, any file with _test.go is a test file. This means they will be ignored when you compile your binary. Tests are first-class citizens in Go and TDD is very easy to implement, as you will see!

In cookies_test.go, let’s add the following lines:

package chapter8_test
import "testing"
func Test_CookiePurchases(t *testing.T) {
   t.Run(`Given a user tries to purchase a cookie and we have them in stock,
      "when they tap their card, they get charged and then receive an email receipt a few moments later.`,
      func(t *testing.T) {})
}

Firstly, you can see we have declared our package as chapter8_test. This is different from cookies.go, which will be declared as in the chapter8 package. The reason I recommend doing this is it ensures you test your code as a consumer. This is called black-box testing. Testing this way makes your tests less brittle because you are not depending on specific implementation details, as they should be private and we will not be able to access them.

Next, we declare a test function. In Go, Test functions start with Test_ and then usually the name of the function we are testing. We haven’t made a function yet, so for now, I have called it CookiePurchases. We might update this later. You’ll see that our function takes a parameter of type *testing.T. This helps Go to identify your test functions and gives us some really helpful testing utilities, which we will see shortly.

Next, we create a closure function using t.Run. This allows you to create sub-tests within a test function and is simply used for grouping related tests. This is not necessary, and you’ll see a lot of code that does not follow this approach. However, I really like it.

Note that I have named my test exactly what was in the acceptance criteria of our ticket. This is where TDD really shows that it is a complementary approach to DDD. The latter is all about ensuring our system is modeling a real-world domain. Ideally, our acceptance criteria should have come from a domain expert. By writing tests like this, we are ensuring our code does match real-world expectations, and it also serves as fantastic documentation for the next developer who comes along and works on our code.

Before we move on to step 2, we will add one more line to our test:

func Test_CookiePurchases(t *testing.T) {
   t.Run(`Given a user tries to purchase a cookie and we have them in stock,
      "when they tap their card, they get charged and then receive an email receipt a few moments later.`,
      func(t *testing.T) {
         t.FailNow()
      })
}

We have added t.FailNow(). Why did we do this? In Go, if a test is empty, it passes immediately. This means we will break our rule that says, “The test should fail.” It also signifies both to our future selves and any other engineers who may work on the code base that this test is incomplete, and we intend to implement it. If you had thousands of tests in your code base and you left this one empty and passing, it could get overlooked, and you could end up with a gap in your testing. Finally, it proves that when we run it in a moment, the Go test harness is set up correctly.

Run the test we just wrote – it should fail (and we should expect it to)

Let’s run our test. In GoLand, I do this by clicking the gutter icon, as seen here. However, you can also run tests from the command line by running go test ./….

Figure 8.3 – Running a test in GoLand by clicking on the left-hand gutter

Figure 8.3 – Running a test in GoLand by clicking on the left-hand gutter

You will see output such as the following:

=== RUN   Test_CookiePurchases
=== RUN   Test_CookiePurchases/Given_a_user_tries_to_purchase_a_cookie_and_we_have_them_in_stock,____"when_they_tap_their_card,_they_get_charged_and_then_receive_an_email_receipt_a_few_moments_later.
--- FAIL: Test_CookiePurchases (0.00s)
    --- FAIL: Test_CookiePurchases/Given_a_user_tries_to_purchase_a_cookie_and_we_have_them_in_stock,____"when_they_tap_their_card,_they_get_charged_and_then_receive_an_email_receipt_a_few_moments_later. (0.00s)
FAIL
Process finished with the exit code 1

Great, our test is failing! Let’s move on to step 3.

Write as little code as possible to pass the test

We currently have not written any code at all. In cookies.go, let’s write the minimum code possible that satisfies the test criteria. The following is my attempt, but as an exercise, please attempt it yourself. Remember that the goal here isn’t beautiful code; it’s to get the test passing.

Here is what I wrote:

package chapter8
import "context"
type (
   EmailSender interface {
      SendEmailReceipt(ctx context.Context, emailAddress string) error
   }
   CardCharger interface {
      ChargeCard(ctx context.Context, cardToken string, amountInCents int) error
   }
   CookieStockChecker interface {
      AmountInStock(ctx context.Context) int
   }
   CookieService struct {
      emailSender  EmailSender
      cardCharger  CardCharger
      stockChecker CookieStockChecker
   }
)
func NewCookieService(e EmailSender, c CardCharger, a CookieStockChecker) (*CookieService, error) {
   return &CookieService{
      emailSender:  e,
      cardCharger:  c,
      stockChecker: a,
   }, nil
}
func (c *CookieService) PurchaseCookies(ctx context.Context, amountOfCookiesToPurchase int) error {
   //TODO: ask how much cookies cost. This is a placeholder.
   priceOfCookie := 5
   cookiesInStock := c.stockChecker.AmountInStock(ctx)
   if amountOfCookiesToPurchase > cookiesInStock {
      //TODO: what do I do in this situation?
   }
   cost := priceOfCookie * amountOfCookiesToPurchase
   //TODO: where do I get cardtoken from?
   if err := c.cardCharger.ChargeCard(ctx, "some-token", cost); err != nil {
      //TODO: handle this later.
   }
   if err := c.emailSender.SendEmailReceipt(ctx, "some-email"); err != nil {
      //TODO: handle error later
   }
   return nil
}

Note that as I wrote the code, I put lots of TODO comments and left notes to myself to either implement functionality later or to check with our domain expert about how they see a specific situation being handled. We will revisit that shortly, but for now, we are not concerned; let’s get our test passing.

In my implementation, I defined some interfaces. We need some mocks of these interfaces to be able to test our code. By mocking interfaces, it allows us to easily create situations in our code that might be otherwise hard to achieve. For example, if we want to test a specific fork in our code when an email doesn’t send, we can do that very easily and do not need to depend on setting up a buggy email infrastructure. Furthermore, interfaces ensure that our code is decoupled from specific implementations. For example, if we were using Google as our email provider and switched to AWS, we would only need to change the adaptor package. (We covered the adaptor pattern earlier in this book.)

The Go team provides a mocking framework called gomock. You can read more about it here: https://github.com/golang/mock. gomock allows you to generate all the code you need to mock an interface. Let’s generate mocks for ours now. To do this, we create gen.go at the root of our project and add the following:

package gen
import _ "github.com/golang/mock/mockgen/model"
//go:generate mockgen -package mocks -destination chapter8/mocks/cookies.go github.com/PacktPublishing/Domain-Driven-Design-with-GoLang/chapter8 CookieStockChecker,CardCharger,EmailSender

You can find more instructions on how this works in the gomock README file. Your gen.go file might look a little different, depending on how you solved the task. Furthermore, if you want to write manual mocks, then that is fine too.

To generate the mocks, we run go generate ./….

If all goes well, we should now see a new directory, as follows:

Figure 8.4 – After generation, the mocks folder should be created

Figure 8.4 – After generation, the mocks folder should be created

Let’s now update our test:

func Test_CookiePurchases(t *testing.T) {
   t.Run(`Given a user tries to purchase a cookie and we have them in stock,
      "when they tap their card, they get charged and then receive an email receipt a few moments later.`,
      func(t *testing.T) {
         var (
            ctrl = gomock.NewController(t)
            e    = mocks.NewMockEmailSender(ctrl)
            c    = mocks.NewMockCardCharger(ctrl)
            s    = mocks.NewMockCookieStockChecker(ctrl)
            ctx = context.Background()
         )
         cookiesToBuy := 5
         totalExpectedCost := 25
         cs, err := chapter8.NewCookieService(e, c, s)
         if err != nil {
            t.Fatalf("expected no error but got %v", err)
         }
         gomock.InOrder(
            s.EXPECT().AmountInStock(ctx).Times(1).Return(cookiesToBuy),
            c.EXPECT().ChargeCard(ctx, "some-token", totalExpectedCost).Times(1).Return(nil),
            e.EXPECT().SendEmailReceipt(ctx, "some-email").Times(1).Return(nil),
         )
         err = cs.PurchaseCookies(ctx, cookiesToBuy)
         if err != nil {
            t.Fatalf("expected no error but got %v", err)
         }
      })
}

In the preceding snippet, we have created instances of the mocks we generated. These mocks satisfy the interfaces we need, so we can call NewCookieService. We then use a utility function of gomock, which allows us to ensure that the interface functions are called only once and with the exact parameters we expect. Finally, we call PurchaseCookies and make sure we get no error.

This test passes and satisfies the criteria outlined in the test description, but we left lots of TODO comments for things we need to clarify with our domain experts. The following outlines the questions I had and the answers the domain expert gave.

Q: How much do cookies cost? Does it ever change?

A: Cookies cost 50 cents. That could change in the future, but for now, they will be that much.

Q: In the event that someone wants to purchase more cookies than we have in stock, what should we do?

A: We should give them the ones we have in stock.

Q: How do we find a user’s card token? Does another team provide this, or do we need to build this functionality?

A: When a customer presents their card, our card machine automatically gives us the token. Therefore, we should have access to the card token.

Q: How do we find a user’s email address?

A: We receive it from the machine automatically, like we do the card token.

Great! We now have learned more about how our system should operate. We should ensure we have test cases that cover these scenarios too. Let’s write them now while we remember.

Our test file now has the following test stubs in it (in addition to the one we filled in):

t.Run(`Given a user tries to purchase a cookie and we don't have any in stock, we return an error to the cashier
      so they can apologize to the customer.`, func(t *testing.T) {
})
t.Run(`Given a user tries to purchase a cookie, we have them in stock, but their card gets declined, we return
   an error to the cashier so that we can ban the customer from the store.`, func(t *testing.T) {
})
t.Run(`Given a user purchases a cookie and we have them in stock, their card is charged successfully but we
   fail to send an email, we return a message to the cashier so they know can notify the customer that they will not
   get an e-mail, but the transaction is still considered done.`, func(t *testing.T) {
})
t.Run(`Given someone wants to purchase more cookies than we have in stock we only charge them for the ones we do have`,
   func(t *testing.T) {
   })

We can now move on to the final TDD step.

Refactoring

We can now refactor our code to make it neater. The only change I will make at this point is to change cookiePrice to 50. This should make our test fail.

After changing cookiePrice to 50, I then run the test again:

--- FAIL: Test_CookiePurchases (0.00s)
=== RUN   Test_CookiePurchases/Given_a_user_tries_to_purchase_a_cookie_and_we_have_them_in_stock,____"when_they_tap_their_card,_they_get_charged_and_then_receive_an_email_receipt_a_few_moments_later.
    cookies.go:42: Unexpected call to *mocks.MockCardCharger.ChargeCard([context.Background some-token 250]) at /Users/matthewboyle/Dev/ddd-golang/chapter8/cookies.go:42 because:
        expected call at /Users/matthewboyle/Dev/ddd-golang/chapter8/cookies_test.go:35 doesn't match the argument at index 2.
        Got: 250 (int)
        Want: is equal to 25 (int)

This is what we expected. Let’s update our test to correct the expected totalPrice:

t.Run(`Given a user tries to purchase a cookie and we have them in stock,
   "when they tap their card, they get charged and then receive an email receipt a few moments later.`,
   func(t *testing.T) {
      var (
         ctrl = gomock.NewController(t)
         e    = mocks.NewMockEmailSender(ctrl)
         c    = mocks.NewMockCardCharger(ctrl)
         s    = mocks.NewMockCookieStockChecker(ctrl)
         ctx = context.Background()
      )
      cookiesToBuy := 5
      totalExpectedCost := 250
      cs, err := chapter8.NewCookieService(e, c, s)
      if err != nil {
         t.Fatalf("expected no error but got %v", err)
      }
      gomock.InOrder(
         s.EXPECT().AmountInStock(ctx).Times(1).Return(cookiesToBuy),
         c.EXPECT().ChargeCard(ctx, "some-token", totalExpectedCost).Times(1).Return(nil),
         e.EXPECT().SendEmailReceipt(ctx, "some-email").Times(1).Return(nil),
      )
      err = cs.PurchaseCookies(ctx, cookiesToBuy)
      if err != nil {
         t.Fatalf("expected no error but got %v", err)
      }
   })

We run the test again and it passes.

Let’s fill in the other tests. Have a go at doing it yourself, and we will walk through the approach as follows:

t.Run(`Given a user tries to purchase a cookie and we don't have any in stock, we return an error to the cashier
      so they can apologize to the customer.`, func(t *testing.T) {
   var (
      ctrl = gomock.NewController(t)
      e    = mocks.NewMockEmailSender(ctrl)
      c    = mocks.NewMockCardCharger(ctrl)
      s    = mocks.NewMockCookieStockChecker(ctrl)
      ctx = context.Background()
   )
   cookiesToBuy := 5
   cs, err := chapter8.NewCookieService(e, c, s)
   if err != nil {
      t.Fatalf("expected no error but got %v", err)
   }
   gomock.InOrder(
      s.EXPECT().AmountInStock(ctx).Times(1).Return(0),
   )
   err = cs.PurchaseCookies(ctx, cookiesToBuy)
   if err == nil {
      t.Fatal("expected an error but got none")
   }
})

This test fails when we run it, as it does not return early with an error, even though we return no cookies in stock. Let’s write some code to ensure this test case passes.

I have added the following code:

func (c *CookieService) PurchaseCookies(ctx context.Context, amountOfCookiesToPurchase int) error {
   priceOfCookie := 50
   cookiesInStock := c.stockChecker.AmountInStock(ctx)
   if cookiesInStock == 0 {
      return errors.New("no cookies in stock sorry :(")
   }
   if amountOfCookiesToPurchase > cookiesInStock {
      //TODO: what do I do in this situation?
   }
…

If we run the test again, it now passes. We should also run the last test to ensure that it still passes too. It does? Great!

Let’s fill in the next test:

t.Run(`Given a user tries to purchase a cookie, we have them in stock, but their card gets declined, we return
   an error to the cashier so that we can ban the customer from the store.`, func(t *testing.T) {
   var (
      ctrl = gomock.NewController(t)
      e    = mocks.NewMockEmailSender(ctrl)
      c    = mocks.NewMockCardCharger(ctrl)
      s    = mocks.NewMockCookieStockChecker(ctrl)
      ctx = context.Background()
   )
   cookiesToBuy := 5
   totalExpectedCost := 250
   cs, err := chapter8.NewCookieService(e, c, s)
   if err != nil {
      t.Fatalf("expected no error but got %v", err)
   }
   gomock.InOrder(
      s.EXPECT().AmountInStock(ctx).Times(1).Return(cookiesToBuy),
      c.EXPECT().ChargeCard(ctx, "some-token", totalExpectedCost).Times(1).Return(errors.New("some error")),
   )
   err = cs.PurchaseCookies(ctx, cookiesToBuy)
   if err == nil {
      t.Fatal("expected an error but got none")
   }
   if err.Error() != "your card was declined, you are banned!" {
      t.Fatalf("error was unexpected, got %v", err.Error())
   }
})

In this test, we are asserting that the call to charge the card fails and we get back a specific error text. Let’s run the test.

It fails as expected with the following error:

=== RUN   Test_CookiePurchases
--- FAIL: Test_CookiePurchases (0.00s)
=== RUN   Test_CookiePurchases/Given_a_user_tries_to_purchase_a_cookie,_we_have_them_in_stock,_but_their_card_gets_declined,_we_return____an_error_to_the_cashier_so_that_we_can_ban_the_customer_from_the_store.
    cookies.go:51: Unexpected call to *mocks.MockEmailSender.SendEmailReceipt([context.Background some-email]) at /Users/matthewboyle/Dev/ddd-golang/chapter8/cookies.go:51 because: there are no expected calls of the method "SendEmailReceipt" for that receiver

This is because an error is never returned when we fail to charge the card. Let’s write the minimal amount of code to fix this:

…
if amountOfCookiesToPurchase > cookiesInStock {
   //TODO: what do I do in this situation?
}
cost := priceOfCookie * amountOfCookiesToPurchase
//TODO: where do I get cardtoken from?
if err := c.cardCharger.ChargeCard(ctx, "some-token", cost); err != nil {
   return errors.New("your card was declined, you are banned!")
}
…

Let’s run our test again:

=== RUN   Test_CookiePurchases
--- PASS: Test_CookiePurchases (0.00s)
=== RUN   Test_CookiePurchases/Given_a_user_tries_to_purchase_a_cookie,_we_have_them_in_stock,_but_their_card_gets_declined,_we_return____an_error_to_the_cashier_so_that_we_can_ban_the_customer_from_the_store.
    --- PASS: Test_CookiePurchases/Given_a_user_tries_to_purchase_a_cookie,_we_have_them_in_stock,_but_their_card_gets_declined,_we_return____an_error_to_the_cashier_so_that_we_can_ban_the_customer_from_the_store. (0.00s)
PASS

Great! It now passes, and our other tests do too. Now is the time to do some refactoring if you want to. I am happy enough with the code for now, so I’m going to move on to the next test:

t.Run(`Given a user purchases a cookie and we have them in stock, their card is charged successfully but we
   fail to send an email, we return a message to the cashier so they know can notify the customer that they will not
   get an e-mail, but the transaction is still considered done.`, func(t *testing.T) {
   var (
      ctrl = gomock.NewController(t)
      e    = mocks.NewMockEmailSender(ctrl)
      c    = mocks.NewMockCardCharger(ctrl)
      s    = mocks.NewMockCookieStockChecker(ctrl)
      ctx = context.Background()
   )
   cookiesToBuy := 5
   totalExpectedCost := 250
   cs, err := chapter8.NewCookieService(e, c, s)
   if err != nil {
      t.Fatalf("expected no error but got %v", err)
   }
   gomock.InOrder(
      s.EXPECT().AmountInStock(ctx).Times(1).Return(cookiesToBuy),
      c.EXPECT().ChargeCard(ctx, "some-token", totalExpectedCost).Times(1).Return(nil),
      e.EXPECT().SendEmailReceipt(ctx, "some-email").Times(1).Return(errors.New("failed to send email")),
   )
   err = cs.PurchaseCookies(ctx, cookiesToBuy)
   if err == nil {
      t.Fatal("expected an error but got none")
   }
   if err.Error() != "we are sorry but the email receipt did not send" {
      t.Fatalf("error was unexpected, got %v", err.Error())
   }
})

Hopefully, this test is clear to you at this point. We are again asserting that certain calls happen but that our email fails to send, and we get a particular error text back.

Brief aside: at this point, I am certain some of you are screaming at me, “Why do you keep repeating the same boilerplate code for setting up a test?! Doesn’t that break the don’t repeat yourself (DRY) principle?!” You are, of course, correct; however, this is a pattern I have landed on after many years of trying to make tests as succinct as code. I find that tests such as the preceding ones are the best documentation we can have, and ensuring that every test has all the information you need to figure out what it is doing outlined clearly is the best way to ensure other engineers (and your future self) can get up to speed with the code base. It’s also the reason I am not a big proponent of table-driven tests, which are popular in the Go community; I feel they prioritize speed for the writer of the code rather than for the future reader. Code is written once but read many times, so we should always prioritize the reader.

Back to our code. We simply add this line:

if err := c.emailSender.SendEmailReceipt(ctx, "some-email"); err != nil {
   return errors.New("we are sorry but the email receipt did not send")
}

This test now passes too.

Let’s move promptly on to the next test. The next one is a little bit more interesting:

t.Run(`Given someone wants to purchase more cookies than we have in stock we only charge them for the ones we do have`,
   func(t *testing.T) {
      var (
         ctrl = gomock.NewController(t)
         e    = mocks.NewMockEmailSender(ctrl)
         c    = mocks.NewMockCardCharger(ctrl)
         s    = mocks.NewMockCookieStockChecker(ctrl)
         ctx = context.Background()
      )
      requestedCookiesToBuy := 5
      inStock := 3
      totalExpectedCost := 150
      cs, err := chapter8.NewCookieService(e, c, s)
      if err != nil {
         t.Fatalf("expected no error but got %v", err)
      }
      gomock.InOrder(
         s.EXPECT().AmountInStock(ctx).Times(1).Return(inStock),
         c.EXPECT().ChargeCard(ctx, "some-token", totalExpectedCost).Times(1).Return(nil),
         e.EXPECT().SendEmailReceipt(ctx, "some-email").Times(1).Return(nil),
      )
      err = cs.PurchaseCookies(ctx, requestedCookiesToBuy)
      if err != nil {
         t.Fatalf("expected no error but got %v", err)
      }
   })

In this test, we are requesting a different number of cookies than are available. As per the domain expert’s requirements, we need to only charge for the ones we have in stock and follow the regular flow apart from that.

This test fails right now, as we are not handling this case:

=== RUN   Test_CookiePurchases
--- FAIL: Test_CookiePurchases (0.00s)
=== RUN   Test_CookiePurchases/Given_someone_wants_to_purchase_more_cookies_than_we_have_in_stock_we_only_charge_them_for_the_ones_we_do_have
    cookies.go:47: Unexpected call to *mocks.MockCardCharger.ChargeCard([context.Background some-token 250]) at /Users/matthewboyle/Dev/ddd-golang/chapter8/cookies.go:47 because:
        expected call at /Users/matthewboyle/Dev/ddd-golang/chapter8/cookies_test.go:159 doesn't match the argument at index 2.
        Got: 250 (int)
        Want: is equal to 150 (int)

Right now, we are charging for cookies we do not have in stock. Let’s fix this:

func (c *CookieService) PurchaseCookies(ctx context.Context, amountOfCookiesToPurchase int) error {
   priceOfCookie := 50
   cookiesInStock := c.stockChecker.AmountInStock(ctx)
   if cookiesInStock == 0 {
      return errors.New("no cookies in stock sorry :(")
   }
   if amountOfCookiesToPurchase > cookiesInStock {
      amountOfCookiesToPurchase = cookiesInStock
   }
   cost := priceOfCookie * amountOfCookiesToPurchase
…

All we have done here is update amountOfCookiesToPurchase = cookiesInStock in the situation where our request is greater. The test now passes!

If we run go test with code coverage now (go test ./... –cover), we will see we have 100% coverage:

Figure 8.5 – 100% test coverage!

Figure 8.5 – 100% test coverage!

This puts us in a great spot to maintain this code going forward, which is ideal, as we are not quite done yet. We have two requirements we have not implemented yet:

  • The domain expert told us we can expect to receive the card token as part of the request
  • The domain expert told us we can receive the email as part of the request

Let’s update our function signature to add both:

func (c *CookieService) PurchaseCookies(
   ctx context.Context,
   amountOfCookiesToPurchase int,
   cardToken string,
   email string,
) error {

This has broken all our tests, as we are now not passing the correct parameter. To fix this, let’s just quickly add any old string to them, such as the following:

…
err = cs.PurchaseCookies(ctx, cookiesToBuy, "a-token", "an-email")
if err != nil {
   t.Fatalf("expected no error but got %v", err)
}
…

If we run our tests, they now all fail. This is because we hardcoded placeholder values for these. Let’s update our code to make our final version of the function:

func (c *CookieService) PurchaseCookies(
   ctx context.Context,
   amountOfCookiesToPurchase int,
   cardToken string,
   email string,
) error {
   priceOfCookie := 50
   cookiesInStock := c.stockChecker.AmountInStock(ctx)
   if cookiesInStock == 0 {
      return errors.New("no cookies in stock sorry :(")
   }
   if amountOfCookiesToPurchase > cookiesInStock {
      amountOfCookiesToPurchase = cookiesInStock
   }
   cost := priceOfCookie * amountOfCookiesToPurchase
   if err := c.cardCharger.ChargeCard(ctx, cardToken, cost); err != nil {
      return errors.New("your card was declined, you are banned!")
   }
   if err := c.emailSender.SendEmailReceipt(ctx, email); err != nil {
      return errors.New("we are sorry but the email receipt did not send")
   }
   return nil
}

Here’s an example of the changes we need to make to each test:

t.Run(`Given a user tries to purchase a cookie and we have them in stock,
   "when they tap their card, they get charged and then receive an email receipt a few moments later.`,
   func(t *testing.T) {
      var (
         ctrl = gomock.NewController(t)
         e    = mocks.NewMockEmailSender(ctrl)
         c    = mocks.NewMockCardCharger(ctrl)
         s    = mocks.NewMockCookieStockChecker(ctrl)
         ctx       = context.Background()
         email     = "[email protected]"
         cardToken = "token"
      )
      cookiesToBuy := 5
      totalExpectedCost := 250
      cs, err := chapter8.NewCookieService(e, c, s)
      if err != nil {
         t.Fatalf("expected no error but got %v", err)
      }
      gomock.InOrder(
         s.EXPECT().AmountInStock(ctx).Times(1).Return(cookiesToBuy),
         c.EXPECT().ChargeCard(ctx, cardToken, totalExpectedCost).Times(1).Return(nil),
         e.EXPECT().SendEmailReceipt(ctx, email).Times(1).Return(nil),
      )
      err = cs.PurchaseCookies(ctx, cookiesToBuy, cardToken, email)
      if err != nil {
         t.Fatalf("expected no error but got %v", err)
      }
   })

In the preceding snippet, we have ensured that our mocks get called with the values passed into the function.

You are now a TDD expert! Hopefully, the value of this iterative approach is clear to you, and you can see how you can work with your domain experts to ensure you are iteratively testing and adding the behavior of your domain. If you want to practice a little more, consider adding these requirements to our code:

  • The card token cannot be empty and must be 12 characters long. In the event it’s empty, we should return an error.
  • The e-mail address must be a valid email, it cannot be empty, and we only support @gmail.com, @yahoo.com, and @msn.co.uk domains. We should return an error if this is not true.
  • If today’s date is January 14, all purchases are free, as it’s our store’s birthday.

You may want to refactor the code a little bit and break some of this logic out into new functions. However, you can do that in the “refactor step” after successfully implementing the functionality.

Now that we understand TDD and how we can use it alongside DDD, let’s look at behaviour-driven development (BDD).

BDD

BDD is an extension of TDD that aims to enable deeper collaboration between engineers, domain experts, and quality assurance engineers (if your company employs them). A diagram of how this works with TDD is shown here.

Figure 8.6 – BDD as an extension of TDD

Figure 8.6 – BDD as an extension of TDD

The goal of BDD is to provide a higher level of abstraction from code through a domain-specific language (often referred to as a DSL) that can become executable tests. Two popular frameworks for writing BDD tests is the use of Gherkin (https://cucumber.io/docs/gherkin/reference/) and Cucumber (https://cucumber.io)

Gherkin defines a set of keywords and a language specification. Cucumber reads this text and validates that the software works as expected. For example, the following is a valid Cucumber test:

Feature: checkout Integration
Scenario: Successfully Capture a payment
Given I am a customer
When I purchase a cookie for 50 cents.
Then my card should be charged 50 cents and an e-mail receipt is sent.

Some teams work with their domain experts to ensure their acceptance criteria in their ticketing system are in this format. If it is, this criterion can simply become the test. This aligns nicely with DDD.

Now that we have a high-level understanding of BDD, let’s take a look at implementing a test in Go. We are going to use the go-bdd framework, which you can find at https://github.com/go-bdd/gobdd.

Firstly, let’s install go-bdd in our project:

go get github.com/go-bdd/gobdd

Now, create a features folder:

Figure 8.7 – Our features folder after creation

Figure 8.7 – Our features folder after creation

Inside the features folder, let’s add a file called add.feature with this inside it:

Feature: Adding numbers
  Scenario: add two numbers together
    When I add 3 and 6
    Then the result should equal 9

Next, let’s add an add_test.go file and the following:

package chapter8
import (
   "testing"
   "github.com/go-bdd/gobdd"
)
func add(t gobdd.StepTest, ctx gobdd.Context, first, second int) {
   res := first + second
   ctx.Set("result", res)
}
func check(t gobdd.StepTest, ctx gobdd.Context, sum int) {
   received, err := ctx.GetInt("result")
   if err != nil {
      t.Fatal(err)
      return
   }
   if sum != received {
      t.Fatalf("expected %d but got %d", sum, received)
   }
}
func TestScenarios(t *testing.T) {
   suite := gobdd.NewSuite(t)
   suite.AddStep(`I add (d+) and (d+)`, add)
   suite.AddStep(`the result should equal (d+)`, check)
   suite.Run()
}

In the preceding code, we add a bdd step function called add. This function name is important; the framework knows that when I add 3 and 6 gets mapped to this function. If you change the name of this function to “sum”, you’d need to update the feature file to say, when I sum 3 and 6 together. We then perform our logic and store it in the context so that we can recall it later.

We then define a check function that is our actual test; it validates our assertions. Finally, we set up a test suite to run our code.

If you run the preceding test, it should pass.

This might be your first time seeing a BDD-style test, but I bet it’s not your first time seeing a unit test. Why is that?

As you can see, although BDD tests are closer to natural language, it pushes a lot of the complexity down into the tests. The preceding example we used is trivial, but if you want to express complex scenarios (such as the cookie example we used previously) there is a lot of scaffolding the developer needs to implement to make the tests work correctly. This can be worthwhile if you have lots of access to your domain experts and you are truly going to work side by side. However, if they are absent or not invested in the process, unit tests are much faster and more engaging for engineering teams to work with. Much like DDD, BDD is a multidisciplinary team investment, and it is worth ensuring you have buy-in from all stakeholders before investing too much time in it.

Summary

In this chapter, we have discussed TDD and BDD and explained how even though they are not necessarily part of the DDD framework, they are certainly complementary patterns that are worth knowing. Even on projects where you do not opt to follow DDD, it is worth following TDD.

Even on side projects, I often use TDD as a form of documentation. It means that if I do not work on the project for multiple months, the tests help me jump straight back in.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset