Unit Testing Interactions With the Operating System in Go | by Leonardo Rodrigues Martins | Sep, 2022

Two ways of testing features that interact with the operating system

Image available at Wikimedia Commons Under the Creative Commons Attribution-ShareAlike 3.0

Testing is a critical aspect to maintain a reliable and solid application, no matter the language or technology used. It makes code less vulnerable to bugs, it prevents us from breaking functionalities that already exist. Otherwise, we would only know about such bugs after many users experienced them.

Ideally, we should try to get a test coverage as high as possible. In applications where we must interact with the operating system, we would need to take extra care regarding the test setup, as it needs a setup of a more specific environment.

In this story, I will show some of the ways that I normally use to set up unit tests of functionalities that interact with the operating system.

Some programs need to interact with the operating system components or artifacts, for instance:

  • Environment Variables
  • File System interactions.
  • Local Network

Performing tests in the environment of the operating system could bring additional issues such as:

  • Problems with permissions.
  • Security Issues
  • Setup of a complex environment.
  • The necessity of cleaning everything up at the end.

These factors can bring unnecessary complexity. Therefore, for unit testing, it is preferable to avoid dealing with them. There are two main strategies that I use to circumvent such problems.

First Strategy: Working with Abstractions

The concept of abstractions is widely employed in Software Engineering. When we build an abstraction of some entity, it consists of a well-defined interface that exposes a behavior that allows us to interact with it, where all the irrelevant implementation details are kept concealed from the outside world.

Then, instead of executing tests running over a real operating system, we can create mocks that work similarly from the perspective of the client.
Imagine we want to test a function that checks if the key “version” is in a certain YAML file.

We can form the following interface to interact with the file system:

type FSReader interface {
ReadFile(string) ([]byte, error)

Also, we can create two concrete types implementing such an interface, one with the real implementation and the other a mock for testing.

As a result, in the case of automated tests, we can check the business logic inside this function without worrying about setting up actual files or cleaning them up. As the mock method would give the same outcome as though we were reading from a real file.

Second Strategy: Stubs

There is an alternative to the use of mocks and abstractions which are known as stubs.

Stubs are a straight replacement of some dependency in the code, it is like running a function or method and taking away all of its inner logic and replacing it with whatever we want to return.

In this way, we can force a certain function to have the effect we want, independently of any other factor regarding it. It is always going to return a predefined output.

In Go, such a functionality is provided by the gostub package.

In the example above, the output from the function MultiplyBy10 was replaced by a stub function, where it returns the input multiplied by 20, instead of 10 as it was written at first.

We can also stub environment variables in the case we use them:

func TestFunctionThatUsesENV(t *testing.T) {
stubs := gostub.New()
stubs.SetEnv("ENV_VAR", "some_value")
defer stubs.Reset()

// Some Logic that Uses ENV_VAR
// ...

Going back to our previous example, where we want to check if the key version can be found inside a certain YAML file. Instead of creating an abstraction and concrete types to implement it, we could directly stub the function responsible for getting the files from the local file system.

For a unit test suite, we could stub the function and restore it at the end:

Other Usages for Stubs or Mocks

I have been using these tools primarily for testing interactions with the operating system in Go. However, they can be applied in many other ways.
Suppose we want to perform unit tests for an API. It is probably going to involve functions and methods that require interacting with a Database or an Email Service, these are some use cases where mocks or stubs could be applied.

In this article, we have seen two ways of testing features that interact with the operating system. In addition, they could also be applied to test other sorts of functionalities.

  • Mocking consists of making abstractions that encapsulate a behavior that we need in our business logic. The client wouldn’t be able to tell the difference between a struct with the real behavior and a mock.
  • Stubbing consists of replacing the logic inside a function, so that it returns exactly the result we want, making a test suite go down the way we plan.

I hope these tips have been useful! Thanks for reading.

News Credit

%d bloggers like this: