In the realm of software development, the creation of robust, dependable, and easily maintainable code stands as a paramount objective. Achieving these goals hinges upon a fundamental practice: the crafting of effective tests. Tests serve a dual purpose. Firstly, they ascertain that your code functions as intended. Secondly, they provide a safety net that empowers you to introduce modifications and enhancements without the looming dread of inadvertently disrupting existing functionalities.
When it comes to writing unit tests in Go, the language follows a well-established structure.
Within the Go ecosystem, adhering to a specific naming convention for test files is essential. For instance, if you have a primary file named module.go
, the accompanying tests will be housed in a file titled module_test.go
.
In the context of the Chaoscenter package, the core packages that undergo testing include experiments
, experiment_runs
, and chaos_hub
. These packages comprise two pivotal files: service.go
and handler.go.
Consequently, the test suite for service.go can be found in service_test.go
, while the tests for handler.go are present within handler_tests.go
.
There are 2 testing packages, used for testing:
testing
: This is the standard Go testing package, which offers functions for executing and asserting tests. (link)testify\mock
: Themock
package, part of thetestify
suite, facilitates the creation of object mocks and the validation of expected method calls. This is very useful in situations where you have to test function making calls to certain enviroments which are absent while testing. For example, a database, or a GitOps system. (link)
In further sections, we shall explore more on them.
According to the Golang testing package, we follow these naming conventions.
func helloWorld() {} // target function
func TestHelloWorld() {} // test function
type FooStruct struct {}
func (f *FooStruct) Bar() {} // target method
func TestFooStruct_Bar(){} // test function
n the Chaos-Center testing methodology, the testing process is divided into three distinct stages: "given," "when," and "then."
- given: This initial stage used to setting up the necessary context for the test. It involves defining the function arguments, constant data required for all tests, configuring mock data if needed, and specifying a boolean parameter indicating whether you are expecting an error to occur.
- when: In this phase, you outline the specific test case you want to evaluate. This could involve invoking a function, method, or action that you intend to test.
- then: The final stage involves executing the actual test. This encompasses calling the function or performing the action established in the "When" stage and subsequently verifying that the outcome matches your expectations.
Here's an illustrative example of a test function adhering to this structure:
func TestStruct_MyFunc(t *testing.T) {
type args struct {
param1 string
param2 string
}
//given
someGlobalTestCont := "some_value"
tests := []struct {
name string
args args
wantErr string
given func()
}{
{
// given
name: "test1",
args: args{
param1: "param1",
param2: "param2",
},
given: func() {
// mock data
// for example mocking the database
},
wantErr: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// when
tc.given()
err := MyFunc(tc.args.param1, tc.args.param2)
// then
if (err != nil) != tc.wantErr {
t.Errorf("MyFunc() error = %v, wantErr %v", err, tc.wantErr)
}
})
}
}
While testing, particularly when dealing with functions that rely on external resources like databases, it's common to employ a technique called mocking. Mocking allows you to simulate the behavior of these external systems, ensuring that your tests remain independent and predictable.
For instance, consider a scenario where a function interacts with a MongoDB database. Instead of making actual database calls during testing, you can create mock implementations to control the responses. For this, the testify/mock library is used.
Here's how you can do it:
- Mocking the MongoDB Operator:
You create a mock version of the MongoDB operator's
Get
method using a library likegithub.com/stretchr/testify/mock
. This mock method expects specific arguments and is programmed to return a predefinedSingleResult
object and an error. When theGet
method is called with specific arguments, it responds with the predeterminedsingleResult
and a nil error. - Predefined Response:
You construct a
SingleResult
object, namedsingleResult
, using predefined data stored in thefindResult
variable. ThissingleResult
represents the response you anticipate the MongoDB operator'sGet
method to return. - Ensuring Compatibility:
It's crucial to ensure that the structure of your mock data (
findResult
) precisely matches the structure of the actual database data. This deep matching (i.e, same key-value pairs) is essential to prevent any potential issues like segmentation faults or errors resulting from disparities between the mock data and real data. Here's a demo implementation of the same
findResult := bson.D{
{Key: "some_key", Value: someValue},
}
singleResult := mongo.NewSingleResultFromDocument(findResult, nil, nil)
mongodbMockOperator.On("Get", mock.Anything, mock.Anything, mock.Anything).Return(singleResult, nil).Once()
mongodbMockOperator.On("Get", mock.Anything, mongodb.ChaosExperimentRunsCollection, mock.Anything).Return(singleResult, nil).Once()
The get function in the mock mongo operator is written as follows:
func (m MongoOperator) Get(ctx context.Context, param1 int, query bson.D) (*mongo.SingleResult, error) {
args := m.Called(ctx, param1, query)
return args.Get(0).(*mongo.SingleResult), args.Error(1)
}
Here, the single result will actually hold the values we want to return which is intern the data in findResult. The mock operator will return the single result, and the error (if any) as well, and hence no database calls are made.
In chaoscenter mocks are available for
- experiments service
- experiment_runs service
- chaos_infrastructure service
- gitops service
- mongo operator