DEV Community

Jon Calhoun
Jon Calhoun

Posted on • Originally published at calhoun.io

More Effective DDD in Go with Interface Test Suites

This article is part of a larger series on Go application structure that was originally posted on calhoun.io, where I write about Go, web dev, testing, and more. I will be porting the entire series over to Dev.to, but I appreciate you checking out on my website and the Go courses that I create 😀

Previously in this series we started to explore domain driven design (DDD) and its benefits. As part of this exploration, we saw that one way to implement DDD in Go is to define interfaces at your domain so that we can write code that is agnostic of implementation details. This allows us to swap out say github for gitlab fairly easily.

Another benefit to having interfaces like these is that we can write tests that run against those interfaces. This not encourages us to write tests focused entirely on behavior, but it also helps ensure that any time we switch one implementation out for another that we won't have unexpected issues. These tests are commonly referred to as interface test suites.

What are interface test suites?

As I stated before, interface test suites are tests that accept an interface and run tests against it. But what does that actually look like?

Imagine that we have an application that needs to verify users. To do this, we might define an AuthService interface that has two methods: Login and Authenticate. The first method is used to log a user in when we don't know who they are and requires their email address and password. After that we generate a token for the user and use that along with the Authenticate method to authenticate them moving forward.

The domain code might end up looking like this:

package app

type Token string

type User struct {
  ID    string
  Email string
}

type AuthService interface {
  Login(email, password string) (Token, error)
  Authenticate(Token) (*User, error)
}

Now we can't test this interface without being provided an implementation, but we can still define some tests that express desired behavior. For instance,we might want to verify that after logging in a user that we can then use the token to authenticate that same user. We might also want to verify that an invalid email address and password combination doesn't somehow result in a token being returned. And there are a number of additional cases we might want to verify.

To do this, we would write a helper function that, given an implementation of AuthService, can run a series of tests to verify that the implementation does what we expect.

package apptest

func AuthService(t *testing.T, as app.AuthService) {
  t.Run("valid login", func(t *testing.T) {
    token, err := as.Login(validEmail, validPw)
    if err != nil {
      t.Errorf("Login() err = %v; want %v", err, nil)
    }
    if len(token) < minTokenLength {
      t.Errorf("len(token) = %v; want >= %v", len(token), minTokenLength)
    }
    // ...
  })
  t.Run("invalid login", func(t *testing.T) {
    // ...
  })
  // ...
}

A common approach is to place this into a subfolder, such as apptest for our app package, so that that is where the package name comes from. We will see later how that is called in an AuthService implementation's test file.

I call this a test suite not because it needs to be some massive thing, but because it isn't actually a test case. It is a helper function used to generate a collection of test cases, which are commonly referred to as a test suite.

Given that this is a test suite, that means these tests won't magically run on their own. We need to call this helper function whenever we implement the authentication service. For instance, if we had a jwt package that implemented the auth service, we might add the following code to it's jwt_test package.

package jwt_test

func TestAuthService(t *testing.T) {
  as := jwt.AuthService(...)
  apptest.AuthService(t, as)
}

In this particular example we are ONLY running the test suite, but you could also write implementation-specific tests if you wish. I am by no means encouraging you to ONLY use interface test suites, but for brevity that is all we see here.

What are the benefits of interface test suites?

When a user signs into our application, we use the Login method to create a token. That token might be a JWT, a remember token, or something else entirely. We don't really care so long as that token works to authenticate the user.

That means we might start with a remember token implementation:

package token

type AuthService struct {
  UserStore interface {
    Authenticate(email, password string) (*app.User, error)
  }
  TokenStore interface {
    User(app.Token) (*app.User, error)
    Create(userID string) (app.Token, error)
  }
}

func (a *AuthService) Login(email, password string) (app.Token, error) {
  user, err := a.UserStore.Authenticate(email, password)
  if err != nil {
    return "", err
  }
  token, err := a.TokenStore.Create(user.ID)
  if err != nil {
    return "", err
  }
  return token, nil
}

func (a *AuthService) Authenticate(t app.Token) (*app.User, error) {
  user, err := a.TokenStore.User(t)
  if err != nil {
    return nil, err
  }
  return user, nil
}

And then we might read a Hacker News article telling us about how cool JWTs are and decide to give them a shot:

package jwt

type AuthService struct {
  UserStore interface {
    Authenticate(email, password string) (*app.User, error)
  }
}

func (a *AuthService) Login(email, password string) (app.Token, error) {
  user, err := a.UserStore.Authenticate(email, password)
  if err != nil {
    return "", err
  }
  token := // build the JWT
  return token, nil
}

func (a *AuthService) Authenticate(t app.Token) (*app.User, error) {
  token, err := Parse(t) // not implemented here
  if err != nil {
    // eg if we couldn't parse the token for any reason - such as an invalid
    // signature
    return nil, err
  }
  // Otherwise pull this data from the JWT. You may need to use fields like
  // "Claims" depending on how you implement this.
  return &app.User{
    ID: token.UserID,
    Email: token.Email,
  }, nil
}

I have intentionally stubbed out or commented out most logic in both of these implementations for brevity, but I think the point still stands; the original AuthService could be implemented in a number of ways.

While we might not care about how each token is generated, there still might be other aspects of the AuthService interface that we care about and want to test across all implementations. For instance, we might expect a token to work for multiple calls to Authenticate rather than expiring after a single use. Or we might expect a specific type of error when a user provides an email address that doesn't exist so that we can inform the user that they do not have an account.

By using interface test suites we are able to express all of these requirements at the domain level, making it clear to anyone who implements the interface what is expected of them.

We are also able to write these tests once, and then with reasonable confidence - that is as long as the test suite is utilized - we can verify that each implementation matches our requirements and is very likely to work in our application.

Additionally, when you do find a bug with one of your implementations you can often write a test at the interface level and ensure that the bug isn't present in any existing or future implementations.

In short, interface test suites pair very nicely with the domain driven design pattern because we often already have interfaces defined as building blocks. They are also a great fit because interface tests can only test behavior, not implementation details, so any tests written this way are less likely to break if our application needs to evolve based on ever-changing requirements.

To reiterate, using interface test suites doesn't mean you can only use interface tests with DDD, but I do find that many unit tests defined at the implementation level can often be replaced with an interface test serving the same purpose which provides a longer lasting benefit.

But what if...

When writing interface test suites, a common problem is that you will realize you need additional data to actually write a useful test. This will usually present itself with a question like, "But what if I don't know what a valid email and password combination is?" or, "But what if I need to reset my auth service between tests and the interface doesn't have a method for that?"

When testing we often need to do things slightly differently from production. I realize that many people will scoff at this remark, but it is true.

When testing anything relating to a database, we will probably need to reset the database between some tests. How often do you reset your database in production? Should there even be a method for that in your production code?

Or when testing an authentication service like we explored here, we might need a way to get some valid email and password combinations. Surely we don't want any way to provide those in a production environment, but in a test we will need access to this information to write a useful test case.

There are a few ways to solve this problem, but my favorite is to just request that additional data in your test suite.

Going back to our authentication service example, we might request functions that will give us valid and invalid credentials for the AuthService implementation.

package apptest

type CredsFn func() (email, pw string)

// valid and invalid are functions we can use to get valid and
// invalid credentials.
func AuthService(t *testing.T, as app.AuthService, valid, invalid CredsFn) {
  t.Run("valid login", func(t *testing.T) {
    validEmail, validPw := valid()
    token, err := as.Login(validEmail, validPw)
    if err != nil {
      t.Errorf("Login() err = %v; want %v", err, nil)
    }
    if len(token) < minTokenLength {
      t.Errorf("len(token) = %v; want >= %v", len(token), minTokenLength)
    }
    // ...
  })
  t.Run("invalid login", func(t *testing.T) {
    invalidEmail, invalidPw := invalid()
    // ...
  })
  // ...
}

Then we can provide this information where we call the interface test suite function.

package jwt_test

func TestAuthService(t *testing.T) {
  validEmail, validPassword := "jon@calhoun.io", "fake-pw"
  us := mockUserStore{
    AuthenticateFn: func(email, password string) (*app.User, error) {
      if email != validEmail || password != validPassword {
        return nil, fmt.Errorf("invalid credentials")
      }
      return &app.User{
        ID: 123,
        Email: validEmail,
      }, nil
    },
  }
  as := jwt.AuthService{
    UserStore: us,
  }
  validFn := func() (email, pw string) {
    return validEmail, validPassword
  }
  invalidFn := func() (email, pw string) {
    return "invalid@email.com", "invalid-pw"
  }
  apptest.AuthService(t, as, validFn, invalidFn)
}

If this starts to become a burden, you can also provide a struct to make it easier to construct these test suites. For example, we might have some optional functions for resetting our auth service before or after each test case:

type AuthServiceSuite struct {
  AuthService *app.AuthService

  // REQUIRED
  Valid CredsFn
  Invalid CredsFn

  // Optional - useful for resetting a DB perhaps.
  BeforeEach func()
  AfterEach func()
}

func AuthService(t *testing.T, as AuthServiceSuite) {
  // ... use the AuthServiceSuite and its AuthService to run tests
}

The bigger point here is that while this data might not be provided as part of the interface definition, it is perfectly acceptable to request it as part of the testing process. Just try to be sure that the information you are requesting isn't locking you into a specific implementation.

One example in the standard library that I like to point people to is the nettest.TestConn test suite. This test suite is designed to test any net.Conn implementation, but interestingly enough it doesn't accept a net.Conn as part of its arguments:

func TestConn(t *testing.T, mp MakePipe)

Instead, TestConn requires a MakePipe type, which is a function that will return two connections such that the test can write to one connection and read whatever was written in the second connection. Additionally, MakePipe also requires a function to stop everything which is used for cleaning up any resources that may need cleaned up.

type MakePipe func() (c1, c2 net.Conn, stop func(), err error)

Note: The stop function here could even be a function that does nothing if your connections don't need cleaned up.

This entire setup allows the TestConn function to create and clean up a brand new pair of net.Conns for each test case. We could even try this pattern with our AuthService test suite.

package apptest

type Credentials struct  {
  Email string
  Password string
}

type MakeAuthService func() (as app.AuthService, teardown func(), valid, invalid Credentials)

// valid and invalid are functions we can use to get valid and
// invalid credentials.
func AuthService(t *testing.T, mas MakeAuthService) {
  t.Run("valid login", func(t *testing.T) {
    as, teardown, valid, invalid := mas()
    defer teardown()
    // run the test using as, valid, and invalid
  })
  t.Run("invalid login", func(t *testing.T) {
    as, teardown, valid, invalid := mas()
    defer teardown()
    // run the test using as, valid, and invalid
  })
}

We could even define a custom type for these subtests to make our tests look more like table driven tests.

type authTest func(t *testing.T, as app.AuthService, valid, invalid Credentials)

// valid and invalid are functions we can use to get valid and
// invalid credentials.
func AuthService(t *testing.T, mas MakeAuthService) {
  for name, test := map[string]authTest{
    // I try to mimic normal testing naming here minus the exporting part
    // You don't have to do that if you don't want to.
    "valid login": testAuthService_validLogin,
    "invalid login": testAuthService_invalidLogin,
    // ...
  }{
    t.Run(name, func(t *testing.T) {
      as, teardown, valid, invalid := mas()
      defer teardown()
      test(t, as, valid, invalid)
    })
  }
}

Wrapping up

While I don't expect everyone to read this and have an immediate use for interface test suites, I do think they are worth thinking about proactively. Interface test suites can be a very powerful tool, especially when paired with DDD.

That said, I am skeptical of suggesting that you use combine interface test suites with TDD. It isn't that it won't work - it definitely can, especially if you already have an implementation of an interface and have already written the interface test suite. Unfortunately, experience has shown me that when people try to practice TDD with interface test suites it can encourage bad behaviors like writing an interface before a use for it naturally evolves from the code. And if you haven't caught on by now, I am a big proponent for letting interfaces surface from your code rather than defining them upfront. After all, we aren't writing Java 😉.

Want to learn Go?

Interested in learning or practicing Go? Check out my FREE courses:

I also have some premium courses that cover Web Dev with Go and Testing with Go that you can check out as well.

Top comments (0)