Skip to main content

you probably don't need to mock

·10 mins

With the clickbait title out of the way, we can discuss why mocking may not be as crucial for Go testing as commonly perceived. Testing is vital to ensuring code reliability and functionality. However, the frequent use of mocks to replicate external dependencies can create a misleading sense of safety and test quality. While useful, mocks often fail to fully reflect the true complexity of system functionalities.

Go is praised for its simplicity and robust testing features, integrated into its language and toolchain, making testing straightforward. By integrating Docker with Go’s testing tools, developers can create tests that more closely replicate production environments, enhancing both maintainability and efficiency. This post explores the view that extensive mocking is unnecessary, proposing that combining Go with Docker can lead to more reliable and simpler tests. I aim to provide Go developers with insights and strategies to enhance their testing approaches, moving beyond the traditional reliance on mocks prevalent in other languages.

Limitations of Mocks in Testing #

Consider the scenario of testing an application’s interaction with a database where developers use mocks to simulate the interactions between the database and your application. Initially effective, but as the application and its database interactions evolve—through added fields, complex queries, and schema changes—the team often spends substantial time updating mocks to match these developments, inadvertently introducing errors and fostering false confidence.

For instance, integrating technologies like Kafka for asynchronous data processing presents challenges. Mocks, while simplifying tests for such systems and the complexities around message delivery, ordering, and failure handling, may not accurately reflect real-world issues such as network latencies or partitioning challenges, leading to unanticipated production failures.

These incidents highlight the limitations of mocks: a critical bug slips into production due to subtle discrepancies in handling conditions like null values. Moreover, when production systems update, existing mocks may no longer accurately represent the interactions, leading to further bugs. An additional personal frustration with mocks is their tendency to cause test suites to expand, sometimes becoming more extensive than the actual code being tested.

These examples underscore the shortcomings of mocks in capturing the full complexity of real systems. Initially beneficial for their speed and isolation, mocks can lead to significant oversights, emphasising the need for testing methods that more accurately replicate the intricacies of production environments. Essentially, mocks test our assumptions about how a system operates, highlighting the critical need for these assumptions to closely align with the actual complexities of the systems under test.

Go’s Approach to Testing #

Go promotes simplicity and efficiency in its testing approach, contrasting with other languages that rely on external frameworks, libraries, and tooling. Go’s integrated testing package and tooling supports tests, benchmarks, and examples directly, simplifying setup and encouraging consistent test writing as part of development workflows. With Go, developers do not need to choose which test library or runner they want to use resulting in a consistent test environment across projects.

A key feature of Go is its use of interfaces for loose coupling, which simplifies testability. Interfaces in Go are implicitly implemented, allowing developers to inject specific implementations for testing, enhancing both code quality and maintainability.

Go also promotes testing practices that closely mirror real-world conditions. For interactions with databases or external systems like message queues, Go, combined with tools like Docker, enables testing against real instances, improving test reliability without the reliance on mocks. While this approach adds complexity, it simplifies certain aspects of testing by eliminating the need for extensive mock setups.

Leveraging Go’s interfaces and integrating practical tools for real-world testing, developers can achieve more dependable, maintainable tests and improve test quality. This method not only makes the testing process more efficient but also ensures greater confidence in the software’s resilience and functionality.

A Go Example #

We’ve explored the limitations of mocks and Go’s testing philosophy. Now, let’s put these concepts into practice with some examples. We’ll examine how reliance on mocks can complicate test code, lead to lower quality tests and mask a bug that may not be caught before going to production.

Consider a simple example of a function that retrieves a leaderboard using Postgres. Typically, database logic is isolated in a separate database or repository package. This function could be a key dependency in our service layer, responsible for fetching the leaderboard for further actions, such as outputting it via an API or sending an email to a tournament winner.

Typically, we would start with a function signature like the following:

type Score struct {
	UserID    int
	Score     int
	Rank      int

type Database struct {
    Conn *pgx.Conn

func (d *Database) GetLeaderboard(ctx context.Context) ([]Score, error)

type Service struct {
	DB *Database

func (s *Service) GetLeaderboard() []string

However, the Database struct presents an immediate issue, as it would require us to develop an elaborate mock to replicate the functionality of pgx.Conn so that we could test it. What you would commonly find in situations like this is in our Service we replace the Database with an interface for handling the retrieval of scores from our database.

To accommodate our mocked example, we would modify our struct to use an interface as follows:

type LeaderboardRetriever interface {
	Get(ctx context.Context) ([]Score, error)

type Service struct {
	LBR LeaderboardRetriever

func (s *Service) GetLeaderboard() []string

We’ve introduced an additional layer of abstraction in our code, enabling us to substitute the pgx.Conn implementation detail with our mock.

At first glance, this new abstraction might seem innocuous, and it appears we can now easily test our Service and function. However, since we are retrieving a leaderboard from the database, our function depends heavily on a crucial part of the SQL query:

    RANK() OVER (ORDER BY t.high_score DESC) AS rank
    MAX(score) AS high_score
    FROM scores
    GROUP BY user_id
) AS t

This query is essential as it fetches the highest score for each user and assigns a rank based on those scores in descending order.

Consider the scenario where we use a mock for testing, making the assumption that our database logic and SQL query is correct and returns the expected scores:

		{UserID: 1, HighScore: 5, Rank: 1},
		{UserID: 2, HighScore: 3, Rank: 2},
	}, nil)

However, imagine if a subtle change were made to the query during development, like altering the ranking order from descending to ascending: RANK() OVER (ORDER BY t.high_score DESC) AS rank becomes RANK() OVER (ORDER BY t.high_score ASC) AS rank. This minor change might be easily overlooked during a code review with hundreds of lines of code. The tests would still pass because the mock assumes the query returns results in the expected order. Such a change could go into production, resulting in a malfunctioning leaderboard. This subtle bug is demonstrated in our repository within the withmocks package.

Using a real Postgres instance for our tests, such as one running in Docker, could help catch this mistake. We could verify that the results returned from the database are correctly ordered through our test assertions.

For a complete code example, visit the repository here.

This example, while basic, does not cover database migrations, seeding, test isolation, and other crucial considerations when using real databases in tests. Here, our custom Go test libraries utilizing Docker come into play.

Practical Alternatives to Mocks in Go #

In Go, the standard library provides a robust foundation for testing via the testing package, yet it does not include built-in support for mock testing. However, the language’s design, particularly its interface system, naturally facilitates mocking. For more sophisticated mocking requirements, third-party libraries and tools are often sought, especially for generating complex mock objects which attempt to simulate the behaviour of real, intricate objects. Nevertheless, Go does have some built-in packages and features that enable alternative methods for testing, meaning developers do not necessarily need to rely on mocks.

An integral part of Go’s design, interfaces naturally lend themselves to creating testable code. By designing components with interfaces, you can easily substitute dependencies with stub implementations for testing purposes. For instance, consider a service that fetches data from an external API. By defining an interface for the API client, you can inject a stub that returns predetermined data during tests, thereby avoiding the need for an actual API call.

Go’s httptest package is another example of the standard library’s support for testing without mocks. It allows developers to create in-memory HTTP servers and clients, facilitating the testing of HTTP interactions in isolation. For example, testing an endpoint by spinning up a temporary HTTP server that returns expected responses can ensure that HTTP clients behave as intended when interacting with a real server.

The alternatives to mocks in Go testing underscore the language’s commitment to simplicity and effectiveness. By utilising interfaces and leveraging the Go standard library, developers can create robust and maintainable tests that faithfully represent production environments. Go encourages developers to experiment with these strategies, enhancing their testing practices while staying true to Go’s principles.

Test Libraries with Go and Docker #

Let’s explore how we can leverage Go’s testing capabilities with Docker to emulate real-world systems, such as queues and other external dependencies, enhancing test reliability and realism.

Docker is ideal for creating isolated test environments. Using a Docker Compose file to set up and link containers ensures consistent settings that mimic production environments closely.

I however prefer a different approach to integrating Docker with Go. I often integrate Docker with Go by building test libraries that run Docker containers for the system under test. I’ve applied this technique, in multiple previous roles, building test libraries for systems like Postgres, Kafka, RabbitMQ, Google Cloud Spanner, and Google Cloud Pub/Sub.

For instance, consider a Go service interacting with a PostgreSQL database. With Docker, we can launch a PostgreSQL container via our Go test library, and run migrations and data seeding, all within the current test. We can extend this method to ensure each test has a separate Postgres database, providing true test isolation between test cases.

Similarly, for services using message queues like Kafka or RabbitMQ, Docker can simulate these systems, allowing us to test the full message processing lifecycle in a controlled environment. An example of this approach is detailed at aranw/kafkatest.

While this strategy offers significant benefits, it is crucial to follow best practices in managing Docker resources, ensuring data isolation between tests, and effectively integrating these tests into CI/CD pipelines. Proper cleanup and resource management are essential for maintaining the efficiency and reliability of your test suite.

Conclusion #

We’ve explored Go’s testing capabilities and Docker’s role in running applications. We’ve underscored the pivotal message: robust testing doesn’t need to rely on traditional mocks. Go’s philosophy, prioritising simplicity and practicality, combined with Docker’s capability to mimic production environments, offers developers a superior approach to crafting meaningful, real-world tests.

We’ve seen how interface-based testing, the httptest package and Docker’s containerisation can lead to tests that are not just aligned with real-life scenarios but are also easier to maintain. These methods enhance the development lifecycle, ensuring applications perform as anticipated in their true operating conditions.

This approach emphasises thorough testing, focusing on how applications perform in their intended environments.

Using Go with Docker meets modern software development needs for building reliable systems. Go developers can use these tools to not only improve testing but also to better understand how applications behave in their environments.

Embracing Go’s testing capabilities and Docker’s ability to emulate our systems under test offers a pathway to not only better testing but also to more insightful software development.

In future posts I plan to write more about how I write tests, the libraries I use and what I believe to be best practices.