Photo by Dimitri Karastelev on Unsplash
Contract Exports in Go
Embracing contract programming for interfaces
We are familiar with design by contract or contract programming. It is a useful technique for checking the correctness of procedures at runtime. When implemented as Hoare triples, the idea is to assert preconditions, invariants, and postconditions of a procedure at runtime. It is so important that some programming languages have facilities for adding these checks without modifying the procedure itself. See Racket Contracts for an example.
In Go, we do not have built-in contract support at the language level. Of course, we can manually write contracts using the language features available. For example, we can return errors if the inputs to a procedure do not meet some requirements, or if the results deviate from some expectation. This technique is useful for procedures that are not exactly exposed as part of a package's public interface. I would restrict its use to such internal procedures.
Specification packages for interfaces
Recently I have been trying a slightly different approach for code that implements an interface. I publish a specs package โ a collection of tests that are parametrised by an interface โ and let the implementers of the interface I define verify their implementation by running these tests against their implementation. This specs package provides the contract as a test suite, which I think is more useful for the implementers of the interface. They can add assertions to their code too.
An example from a public package
For an example of specification packages in practice, you can refer to my experiment with implementing an iterators package using generics in Go. In that repository, I defined an Iterator[T]
interface at the root of the package. I then provided a specs
package that exports test routines for specific properties that I expect every implementation of Iterator[T]
to guarantee. The tests for the slice and map implementations of Iterator[T]
pass their implementations to these test routines to validate them.
An example from a private codebase
I also use this technique at work. In one package, I defined a repository for persisting a struct called Session
.
type SessionRepository interface {
Save(context.Context, Session) error
Load(context.Context, uuid.UUID) (*Session, error)
}
This interface is defined by the package that needs to persist Session
values. In that package, we can define our domain logic and use persistence without committing to any persistence implementation, as an application of the hexagonal architecture. We also export a specs
subpackage that provides the following test routines for implementations of SessionRepository
.
func Test_store_save_persists_session(
ctx context.Context,
t *testing.T,
store pkg.SessionRepository,
session pkg.Session,
) {
t.Helper()
require.False(t, session.ID.IsNil())
err := store.Save(ctx, session)
require.NoError(t, err)
savedSession, err := store.Load(ctx, session.ID)
require.NoError(t, err)
require.NotNil(t, savedSession)
sameSession := session == *savedSession
require.True(t, sameSession)
}
func Test_store_save_is_idempotent(
ctx context.Context,
t *testing.T,
store pkg.SessionRepository,
session pkg.Session,
) {
t.Helper()
require.False(t, session.ID.IsNil())
err := store.Save(ctx, session)
require.NoError(t, err)
err = store.Save(ctx, session)
require.NoError(t, err)
savedSession, err := store.Load(ctx, session.ID)
require.NoError(t, err)
require.NotNil(t, savedSession)
sameSession := session == *savedSession
require.True(t, sameSession)
}
func Test_store_load_returns_nil_for_non_existent_session(
ctx context.Context,
t *testing.T,
store pkg.SessionRepository,
id uuid.UUID,
) {
t.Helper()
s, err := store.Load(ctx, id)
require.NoError(t, err)
require.Nil(t, s)
}
While testing the domain logic, I created a fake implementation of SessionRepository
and passed it to these test procedures to verify them. When I define an implementation that uses a real database, I will pass an instance of it to these test procedures too.
Perhaps this technique has already been explained better by someone else and I just came to it in my ignorance. I would love to see earlier examples. I would also love to learn about other approaches to contract testing as well as potential blindspots in the approach that I have outlined in this article. Feel free to share your thoughts in the comments, and remember to honour your contracts. ๐
Updates
2023-07-10
Today I learned that this technique is a Layer Test, specifically a Persistence Layer Test (Gerard Meszaros, 2007). However, it features a slight difference: the tests are specified as a contract for all implementations of the interface rather than for a single implementation, as in Testing the Persistence Layer With Spring Boot @DataJpaTest. Both fake implementations and real implementations must pass these tests to provide sufficient confidence in using the fakes.
Of course, real implementations may have specific behaviours unique to their implementation technology and its setup, and we can test those separately. But those details are not part of the contract of the interface, so they don't go into the tests for the contract. They go into the tests for the implementation. These implementation-specific tests may check high availability and scalability configuration, driver error handling, connection pooling, crash recovery and reconnection, etc.