2

Unit Test Strategies With Go

 1 year ago
source link: https://medium.com/trendyol-tech/unit-test-strategies-with-go-b892c2ccb61a
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Unit Test Strategies With Go

You can apply different strategies when it comes to writing tests in Go. As the Trendyol Indexing Team, we often use FunctionPerTest in our unit tests. At some point, we realized that we need to duplicate the test code for some type of code. After realizing that, we started looking for different test strategies to overcome this problem and found Table-Driven Tests.

We examined the Table-Driven Test approach and started using it for some of our test cases. Both structures are designed to improve the readability of our test code, but each approach has its advantages and disadvantages.

In our Special Interest Time* sessions, we decided to compare them to see which approach we can use in which scenario. In this article, I will show the results we achieved.

1*TjdLn3gxJYrOF3K53R3fyg.png

To compare these two approaches, we wrote test codes for our DateTime Parser method. You can reach the article my friend wrote about how we handled UTC formats in Go from this link.

The method takes a date-time string and returns the parsed Time object with an error object.

In the test code, we have to check if the input date is parsed without error and if the returned value is correct.

FunctionPerTest Approach

func Parse_Time_Successfully_When_Given_Date_Format_Is_Correct(t *testing.T) {
// Given
dateString := "2022-03-09T03:30:00.000Z"

// When
time, err := ParseToTimeWithUtc(dateString)

// Then
assert.NoError(t, err)
assert.Equal(t, int64(1646796600000), time.UnixMilli())
}

func Return_0_And_Error_When_Given_Date_Format_Is_Not_Correct(t *testing.T) {
// Given
dateString := "2022-03-09T06:30:00"

// When
time, err := ParseToTimeWithUtc(dateString)

// Then
assert.Error(t, err)
assert.Equal(t, int64(0), time.UnixMilli())
}

First, we wrote a test code using the FunctionPerTest approach.

For the first test case, the code was straightforward to write. It was clear what to do in each part:
Given -> Specify the input
When -> Call the parse method
Then -> Make the assertions

It was easy to read because the code was small and isolated from each other.

When it came to the following cases, things started to get dirty. We must duplicate the same method for each case and change only the input value and the expected result. As test cases increased, the test code got worse. Because we wanted to test all the different scenarios, we had to write multiple duplicate test methods for the same code block. Because of the duplications and the increased line count, the test codes became so hard to read. It was hard to navigate between different cases and find a specific case.

When we wanted to change a small part of the code, like changing the type of input, we also had to change it on each test method. The maintainability of the test code became impossible.

There was no structure in the test code. If we wanted to add another assertion to one of the test cases, we don’t have to add it to the other cases. After a while, in some cases, we may start to forget to write some assertions even though we had to.

Summarization:

  • Test Writing Speed/Ease: Easy at first but becomes complicated after adding more cases
  • Readability: Straightforward when you have fewer cases, becomes hard after adding more cases
  • Simplicity: No structure in tests and may make duplications so complexity increases
  • Maintainability: Hard to maintain, code changes affect each test code

Table-Driven Test

func TestParseDate(t *testing.T) {
m := func(millis int) int {
return millis * 1000000
}

tests := []struct {
date string
expected time.Time
wantErr assert.ErrorAssertionFunc
}{
{"2022-03-09T03:30:11.123Z", time.Date(2022, time.March, 9, 3, 30, 11, m(123), time.UTC), assert.NoError},
{"2022-03-09T03:30:11.12Z", time.Date(2022, time.March, 9, 3, 30, 11, m(120), time.UTC), assert.NoError},
{"2022-03-09T03:30:11.1Z", time.Date(2022, time.March, 9, 3, 30, 11, m(100), time.UTC), assert.NoError},
{"2022-03-09T03:30:11Z", time.Date(2022, time.March, 9, 3, 30, 11, m(0), time.UTC), assert.NoError},
{"2022-03-09T03:30:11.816253Z", time.Date(2022, time.March, 9, 3, 30, 11, 816253000, time.UTC), assert.NoError},
{"2022-03-09T03:30:11.Z", time.UnixMilli(0), assert.Error},
{"2022-03-09T03:30:11", time.UnixMilli(0), assert.Error},
{"invalid", time.UnixMilli(0), assert.Error},
}

for _, test := range tests {
t.Run(test.date, func(t *testing.T) {
tm, err := ParseToTimeWithUtc(test.date)
assert.Equal(t, test.expected, tm)
test.wantErr(t, err)
})
}
}

After the FunctionPerTest approach, we wrote table tests for the same method.

The first case was hard to write because we needed to construct the structure of the table test. We didn’t have enough experience with that approach so we need to make some search on the internet. The IDE we used was capable of generating test code in the Table-Driven Testing approach, so we turned to the IDE for help and wrote our first test on the generated code.

func TestParseDate(t *testing.T) {
m := func(millis int) int {
return millis * 1000000
}

tests := []struct {
date string
expected time.Time
wantErr assert.ErrorAssertionFunc
}{
{"2022-03-09T03:30:11.123Z", time.Date(2022, time.March, 9, 3, 30, 11, m(123), time.UTC), assert.NoError},
{"2022-03-09T03:30:11.12Z", time.Date(2022, time.March, 9, 3, 30, 11, m(120), time.UTC), assert.NoError},
{"2022-03-09T03:30:11.1Z", time.Date(2022, time.March, 9, 3, 30, 11, m(100), time.UTC), assert.NoError},
{"2022-03-09T03:30:11Z", time.Date(2022, time.March, 9, 3, 30, 11, m(0), time.UTC), assert.NoError},
{"2022-03-09T03:30:11.816253Z", time.Date(2022, time.March, 9, 3, 30, 11, 816253000, time.UTC), assert.NoError},
{"2022-03-09T03:30:11.Z", time.UnixMilli(0), assert.Error},
{"2022-03-09T03:30:11", time.UnixMilli(0), assert.Error},
{"invalid", time.UnixMilli(0), assert.Error},
}

for _, test := range tests {
t.Run(test.date, func(t *testing.T) {
tm, err := ParseToTimeWithUtc(test.date)
assert.Equal(t, test.expected, tm)
test.wantErr(t, err)
})
}
}

The test code forced us to write structured tests. When we add new assertions, this allowed us to make sure that the assertion affects all cases. In this way, the possibility of adding missing controls has been reduced.

The table-driven test structure allowed us to keep the code more organized and readable. We could add the cases with almost no effort. Additional tests were easy to write, so we implemented more test cases than the other approach. We could write new cases just by changing the inputs.

Summarization:

  • Test Writing Speed/Ease: Hard at first but easy to add new cases
  • Readability: Easy to read because of the structure
  • Simplicity: Complexity and duplications decrease because of the structure
  • Maintainability: Easy to maintain, easy to add new inputs and assertions

Additional Observations

We’ve found that table-driven tests make things a lot easier when you have a large number of test cases that differ only in their input. We decided to expand our work to ensure this approach works for more complex tests. After observing the advantages and disadvantages of both strategies on a basic method, we applied the table-driven test approach to one of our complex methods which makes service calls.

This method already had test code written with the FunctionPerTest approach so we used them to compare with the table test. In the test, we needed to create mocks of the services and control if the correct number of service calls was made. Below, you can find the outcomes of our observations.

FunctionPerTest
The structure allows us flexibility regarding what you can test and how you write the test code. That feature made it a good choice for testing a method with complex functionality and edge cases.

In terms of maintainability, the test code became complex and hard to change. The complexity of the test code made readability hard. After a while, we got lost in the test class trying to switch between different test cases.

Table-Driven Test
It was difficult to write the same test with this approach because we had to write a lot of code just to set up the build. We had to decide our struct model and expected values.

After writing the first case, the test code became easy to read and maintain. Adding new test methods was hard but adding new cases to the existing tests was relatively easier than the FunctionPerTest approach.

Even though adding new cases became easy, tests became too big to read. Mocking logic was also harder because of the conditional branches. After a while, we had to either add logical expressions to the code or separate these tests. Both approaches made the complexity and readability worse than ever.

Both FunctionPerTest and Table-Driven Test approaches have their benefits and drawbacks. Choosing which one to use depends on your code’s functionality and requirements, and will affect the complexity and maintainability of your test code.

To summarize the results of our work:

FunctionPerTest can be chosen if you have a code with complex logic and service calls. Writing the tests with this approach is easy when you don’t have many test cases, but can get complicated and hard to handle as the number of your test cases increase.

Table-Driven Test is more useful when you have a simple code with a lot of similar test cases that only differ by the data used in the test. By using a table, you can easily change the data for each test, without having to rewrite the entire test.

If you’re interested in joining our team, you can apply for the role of backend developer or any of our current open positions.

(*) Special Interest Time is a specific period that we decide on as a team so that all members could research any topic they have an interest in.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK