Go At Speed

Share this post

Testing generic functions

goatspeed.substack.com

Testing generic functions

Some thoughts after digging a bit deeper

Ollie
Mar 2
1
Share this post

Testing generic functions

goatspeed.substack.com

After spending some time experimenting with generics and generic functions in particular, I got to the topic of testing them - the subject of this post.

I’ve seen some complex syntax used to scaffold unit tests on generic functions. Unless I’m missing something, that additional complexity offers little-to-no additional utility.

Let me elaborate with an example. Consider this simple generic function.

package generic

type Numeric interface {
   int | int8 | int16 | int32 | int64 | uint |
   uint8 | uint16 | uint32 | uint64 |
   float32 | float64
}

type Addable interface {
   Numeric | string
}

func AddAny[T Addable](x, y T) T {
   return x + y
}

Though simple, it’s an ideal demonstration of the usefulness of generics. Imagine the duplication or type switch logic to handle all these types before generics!

My trusty IDE, Goland can scaffold a test function for me, which I’ve simplified to this syntax.

package generic

import "testing"

func TestAddAny(t *testing.T) {
   type testCase[T Addable] struct {
      name string
      x    T
      y    T
      want T
   }

   tests := []testCase[ TODO: Insert concrete types here ]{
      // TODO: Add test cases.
   }
   for _, tt := range tests {
      t.Run(tt.name, func(t *testing.T) {
         if got := AddAny(tt.x, tt.y); got != tt.want {
            t.Errorf("AddAny() = %v, want %v", got, tt.want)
         }
      })
   }
}

The testCase struct definition uses generics. The fields x, y and want use the type placeholder T. We can see that T uses the custom type constraint, Addable.

It looks like a good starting point. All we need to do is add some test cases, ideally one case for each concrete type we’re interested in testing?

But there’s a problem. At the point we actually create the test cases we need to tell the compiler what type we are choosing to use to represent T.

Remember, T is a placeholder for type and Addable is the type constraint which tells the compiler which types are allowed.

Contrary to what the message appears to imply, we can’t pass multiple types as a constraint list, it has to be a single type, say, int8, which means that for each testCase struct we create in that slice, x, y and want are all now int8 values.

func TestAddAny(t *testing.T) {
   type testCase[T Addable] struct {
      name string
      x    T
      y    T
      want T
   }

   tests := []testCase[int8]{
      {
         "first test",
         2,
         2,
         4,
      },
      {
         "second test",
         0,
         -2,
         -2,
      },
   }

   for _, tt := range tests {
      t.Run(tt.name, func(t *testing.T) {
         if got := AddAny(tt.x, tt.y); got != tt.want {
            t.Errorf("AddAny() = %v, want %v", got, tt.want)
         }
      })
   }
}

What if we want to test int32 as well?

Or the string type: which would definitely be worth testing as the addition operator works differently for strings, concatenating them instead of summing them. We probably want to capture both behaviours in our tests?

Well, it would seem that we need another table of tests specifically for a second type?

On the upside, because the testCase struct is generic, I don’t need to have a separate struct for each type I want to use in my test cases.

But also note, we have to use a named struct. As far as I can tell we cannot use an anonymous struct with generics, which makes sense, because an anonymous struct is declared and a value of it created at the point of use. But, many of us are used to using such structs in our table tests since the struct doesn’t need to exist beyond the test itself.

Some pros, some cons. So how different is this in practice?

Let’s create tests to test both int8 and string types using both approaches: the generic approach we’ve shown so far and; the concrete approach we’d normally use.

Generic approach

// Generic testing approach
package generic

import "testing"

type testCase[T Addable] struct {
   name string
   x    T
   y    T
   want T
}

func TestAddAnyGenInt8(t *testing.T) {
   t.Parallel()
   tests := []testCase[int8]{
      {
         "first test",
         2,
         2,
         4,
      },
      {
         "second test",
         0,
         -2,
         -2,
      },
   }

   for _, tt := range tests {
      t.Run(tt.name, func(t *testing.T) {
         if got := AddAny(tt.x, tt.y); got != tt.want {
            t.Errorf("AddAny() = %v, want %v", got, tt.want)
         }
      })
   }
}

func TestAddAnyGenString(t *testing.T) {
   t.Parallel()
   tests := []testCase[string]{
      {
         "first test string",
         "hello",
         " world",
         "hello world",
      },
      {
         "second test string",
         "My name is ",
         "Joe Blogs",
         "My name is Joe Blogs",
      },
   }

   for _, tt := range tests {
      t.Run(tt.name, func(t *testing.T) {
         if got := AddAny(tt.x, tt.y); got != tt.want {
            t.Errorf("AddAny() = %v, want %v", got, tt.want)
         }
      })
   }
} 

With concrete types and anonymous structs

// Concrete testing approach
package generic

import "testing"

func TestAddAnyInt8(t *testing.T) {
   t.Parallel()
   tests := []struct {
      name string
      x    int8
      y    int8
      want int8
   }{
      {
         "first test",
         2,
         2,
         4,
      },
      {
         "second test",
         0,
         -2,
         -2,
      },
   }

   for _, tt := range tests {
      t.Run(tt.name, func(t *testing.T) {
         if got := AddAny(tt.x, tt.y); got != tt.want {
            t.Errorf("AddAny() = %v, want %v", got, tt.want)
         }
      })
   }
}

func TestAddAnyString(t *testing.T) {
   t.Parallel()
   tests := []struct {
      name string
      x    string
      y    string
      want string
   }{
      {
         "first test string",
         "hello",
         " world",
         "hello world",
      },
      {
         "second test string",
         "My name is ",
         "Joe Blogs",
         "My name is Joe Blogs",
      },
   }

   for _, tt := range tests {
      t.Run(tt.name, func(t *testing.T) {
         if got := AddAny(tt.x, tt.y); got != tt.want {
            t.Errorf("AddAny() = %v, want %v", got, tt.want)
         }
      })
   }
}

So there’s not much in it in terms of syntax. Granted if we wanted to test more types with our SumAny function the generic approach would save code, but, to test every type sort of negates the purpose of generic programming. I only tested string to capture the concatenation versus addition behaviour.

Did you know you can buy my book Go Faster on LeanPub.com?

To summarise, I think what I’d hoped to be able to do was something like the below, using the type constraint in the slice while pushing the creation of the testCase with a concrete type into testCase struct.

tests := []testCase[Addable]{
   testCase[int8]{
      "first test",
      2,
      2,
      4,
   },
   testCase[string]{
      "second test",
      "hello ",
      "world,
      "hello world",
   },
}

I totally understand why I can’t do this, because a slice, like an array may contain a single type, but for me this would have been optimal, allowing me to create a single table of test cases, with each case testing a specific concrete type of interest against the generic function.

With the above knowledge, and given that in most cases we shouldn’t need to test multiple types with a generic function - or what is the point of generic programming - I think my preferred approach is to use specific types when writing tests and reserve generic programming for implementation purposes.

I’ve left the code here if you want to play around for yourself.

Share this post

Testing generic functions

goatspeed.substack.com
Comments
TopNewCommunity

No posts

Ready for more?

© 2023 Ollie
Privacy ∙ Terms ∙ Collection notice
Start WritingGet the app
Substack is the home for great writing