Protobuf message serialization in unit tests is surprisingly brittle, often failing not because your logic is wrong, but because a subtle change in the generated code or your test setup breaks the wire format expectations.

Let’s see this in action. Imagine you have a simple User message:

// user.proto
syntax = "proto3";

message User {
  string name = 1;
  int32 age = 2;
  repeated string tags = 3;
}

And you generate Go code from it. Your test might look like this:

package main

import (
	"testing"

	"github.com/golang/protobuf/proto" // Or your protobuf implementation
	"your_module/pb" // Generated protobuf code
)

func TestUserSerialization(t *testing.T) {
	user := &pb.User{
		Name: "Alice",
		Age:  30,
		Tags: []string{"developer", "golang"},
	}

	// Serialize
	data, err := proto.Marshal(user)
	if err != nil {
		t.Fatalf("Failed to marshal user: %v", err)
	}

	// Deserialize
	newUser := &pb.User{}
	err = proto.Unmarshal(data, newUser)
	if err != nil {
		t.Fatalf("Failed to unmarshal user: %v", err)
	}

	// Verify
	if newUser.GetName() != "Alice" {
		t.Errorf("Expected name 'Alice', got '%s'", newUser.GetName())
	}
	if newUser.GetAge() != 30 {
		t.Errorf("Expected age 30, got %d", newUser.GetAge())
	}
	if len(newUser.GetTags()) != 2 {
		t.Errorf("Expected 2 tags, got %d", len(newUser.GetTags()))
	}
	if newUser.GetTags()[0] != "developer" {
		t.Errorf("Expected first tag 'developer', got '%s'", newUser.GetTags()[0])
	}
}

This seems straightforward. You create a User, serialize it to bytes, then deserialize those bytes back into a new User and check if the values match. The core idea is that if you can serialize and deserialize without error and the data round-trips correctly, your message structure and the protobuf library are working as expected for that specific message.

The problem arises when the generated code changes, or when you introduce subtle issues. Protobuf serialization is a stable wire format, but the implementation of serializing and deserializing your specific Go structs into that format can be sensitive.

Here’s how it works under the hood: Protobuf defines a binary encoding (like varints for integers, length-delimited for strings/bytes, etc.). When you call proto.Marshal, the Go struct fields are mapped to these Protobuf types and written to a byte slice according to the rules. proto.Unmarshal reads these bytes, interprets them based on the field tags and types, and populates the Go struct. The generated code contains the mapping logic.

The most common reason for failure is a mismatch between the Protobuf compiler version and the runtime library version. If you update your protoc binary to a newer version but keep an older protoc-gen-go plugin, or vice-versa, the generated code might use a serialization format that the older runtime library doesn’t understand, or vice-versa. This often manifests as a proto.Unmarshal error, like proto: illegal tag ... or proto: invalid wire-type.

To fix this, ensure your protoc binary and your protobuf Go plugin (protoc-gen-go) are compatible and ideally the same major version. Check protoc --version and the version of protoc-gen-go you installed (e.g., go list -m github.com/golang/protobuf). If they differ, update them to match. This guarantees the generated code and runtime library speak the same serialization language.

Another frequent culprit is unexpected default values. In proto2, unset fields would not be serialized, but in proto3, scalar fields are serialized to their zero values (e.g., 0 for int32, "" for string, false for bool). If your test assumes a field won’t be present when it actually is present as a zero value, deserialization might seem correct, but subsequent checks could fail. For example, if you had a bool is_admin = 4; field and proto.Unmarshal populates it with false (the default), but your test expects it to be absent, your test logic breaks. The fix is to explicitly set all fields in your test User struct to non-default values if you intend to test them, or to understand and test for the zero-value behavior of proto3.

A subtle but critical issue is incorrect field numbering in the .proto file. Each field in a Protobuf message must have a unique, positive integer tag. If you accidentally reuse a tag or use a negative/zero tag, the generated code will be incorrect, and serialization/deserialization will fail. The fix is to meticulously review your .proto file for unique and positive field tags. For instance, if Age was mistakenly set to 1 and Name also 1, you’d see serialization errors. Change one of them, e.g., Age = 2.

The use of repeated fields can also be a source of error. If you serialize a message with an empty repeated field (e.g., Tags: []string{}), proto3 serializes this as an empty length-delimited field. If your test expects it to be entirely absent (which is how proto2 would handle it, or how an unset field behaves), it might lead to unexpected results. Ensure your tests correctly account for how empty repeated fields are encoded in proto3. Serializing User{Name: "Bob", Age: 25, Tags: []string{}} results in bytes that, when unmarshaled, will yield a User with an empty Tags slice, not a nil slice.

If you’re using custom options or extensions, these can introduce complexity. Ensure that any custom option definitions in your .proto files are correctly processed by the Protobuf compiler and that the runtime library has the necessary logic to handle them. Mismatched or missing custom option definitions are a common cause of obscure errors during marshaling or unmarshaling.

Finally, consider the data types themselves. While Protobuf has standard mappings, very large integers, specific byte sequences, or complex nested structures can sometimes reveal edge cases in the serialization logic of the Go protobuf library. If you serialize a User with Age = 9223372036854775807 (the maximum int64), ensure your test accounts for potential overflow or precision issues if the underlying Go type is int and not explicitly int64. The fix is to use explicit types like int64 in your .proto file and ensure your Go code uses corresponding types, and to test with boundary values.

After fixing all serialization issues, the next error you’ll likely encounter is a nil pointer dereference if your unmarshaled struct is accessed before checking for nil after a potential Unmarshal error.

Want structured learning?

Take the full Protobuf course →