Skip to content

refactor: test error semantics #112

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions internal/libevm/errs/errs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Copyright 2025 the libevm authors.
//
// The libevm additions to go-ethereum are free software: you can redistribute
// them and/or modify them under the terms of the GNU Lesser General Public License
// as published by the Free Software Foundation, either version 3 of the License,
// or (at your option) any later version.
//
// The libevm additions are distributed in the hope that they will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
// General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with the go-ethereum library. If not, see
// <http://www.gnu.org/licenses/>.

// Package errs provides a mechanism for [testing error semantics] through
// unique identifiers, instead of depending on error messages that may result in
// change-detector tests.
//
// [testing error semantics]: https://google.github.io/styleguide/go/decisions#test-error-semantics
package errs

import (
"errors"
"fmt"
)

// An ID is a distinct numeric identifier for an error. Any two errors with the
// same ID will result in [errors.Is] returning true, regardless of their
// messages.
type ID int

// An identifier performs ID comparison, for embedding in all error types to
// provide their Is() method.
type identifier struct {
id ID
}

func (id identifier) errorID() ID { return id.id }

func (id identifier) Is(target error) bool {
t, ok := target.(interface{ errorID() ID })
if !ok {
return false
}
return t.errorID() == id.errorID()
}

func (id ID) asIdentifier() identifier { return identifier{id} }

// WithID returns a new error with the ID and message.
func WithID(id ID, msg string) error {
return noWrap{errors.New(msg), id.asIdentifier()}
}

type noWrap struct {
error
identifier
}

// WithIDf is the formatted equivalent of [WithID], supporting the same
// wrapping semantics as [fmt.Errorf].
func WithIDf(id ID, format string, a ...any) error {
switch err := fmt.Errorf(format, a...).(type) {
case singleWrapper:
return single{err, id.asIdentifier()}
case multiWrapper:
return multi{err, id.asIdentifier()}
default:
return noWrap{err, id.asIdentifier()}
}
}

type singleWrapper interface {
error
Unwrap() error
}

type single struct {
singleWrapper
identifier
}

type multiWrapper interface {
error
Unwrap() []error
}

type multi struct {
multiWrapper
identifier
}
71 changes: 71 additions & 0 deletions internal/libevm/errs/errs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Copyright 2025 the libevm authors.
//
// The libevm additions to go-ethereum are free software: you can redistribute
// them and/or modify them under the terms of the GNU Lesser General Public License
// as published by the Free Software Foundation, either version 3 of the License,
// or (at your option) any later version.
//
// The libevm additions are distributed in the hope that they will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
// General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with the go-ethereum library. If not, see
// <http://www.gnu.org/licenses/>.

package errs

import (
"errors"
"fmt"
"testing"

"github.com/stretchr/testify/assert"
)

func TestIs(t *testing.T) {
ids := []ID{0, 42}
errsByID := make(map[ID][]error)
for _, id := range ids {
errsByID[id] = []error{
WithID(id, "WithID()"),
WithIDf(id, "WithIDf() no wrapping"),
WithIDf(id, "WithIDf() wrap one %w", errors.New("x")),
WithIDf(id, "WithIDf() wrap multi %w + %w", errors.New("x"), errors.New("y")),
}
}

unidentified := []error{
errors.New("errors.New()"),
fmt.Errorf("fmt.Errorf()"),
}

for id, errs := range errsByID {
for _, err := range errs {
for targetID, targets := range errsByID {
want := id == targetID
for _, target := range targets {
assert.Equalf(t, want, errors.Is(err, target), "errors.Is(%v [ID %d], %v [ID %d])", err, id, target, targetID)
}
}

for _, target := range unidentified {
assert.NotErrorIsf(t, err, target, "error ID %d", id)
}
}
}
}

func Example() {
id42 := WithID(42, "hello")
alsoWithID42 := WithIDf(42, "%s", "world")
unidentified := errors.New("hello")

fmt.Println(errors.Is(id42, alsoWithID42))
fmt.Println(errors.Is(id42, unidentified))

// Output:
// true
// false
}
24 changes: 18 additions & 6 deletions params/json.libevm.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ package params
import (
"encoding/json"
"fmt"

"github.com/ava-labs/libevm/internal/libevm/errs"
)

var _ interface {
Expand All @@ -43,20 +45,30 @@ func (c *ChainConfig) UnmarshalJSON(data []byte) (err error) {
return UnmarshalChainConfigJSON(data, c, c.extra, ec.reuseJSONRoot)
}

// Internal error identifiers for precise testing.
const (
errIDDecodeJSONIntoCombination errs.ID = iota
errIDDecodeJSONIntoExtra
errIDEncodeJSONCombination
errIDEncodeExtraToRawJSON
errIDEncodeDuplicateJSONKey
errIDNilExtra
)

// UnmarshalChainConfigJSON is equivalent to [ChainConfig.UnmarshalJSON]
// had [Extras] with `C` been registered, but without the need to call
// [RegisterExtras]. The `extra` argument MUST NOT be nil.
func UnmarshalChainConfigJSON[C any](data []byte, config *ChainConfig, extra *C, reuseJSONRoot bool) (err error) {
if extra == nil {
return fmt.Errorf("%T argument is nil; use %T.UnmarshalJSON() directly", extra, config)
return errs.WithIDf(errIDNilExtra, "%T argument is nil; use %T.UnmarshalJSON() directly", extra, config)
}

if reuseJSONRoot {
if err := json.Unmarshal(data, (*chainConfigWithoutMethods)(config)); err != nil {
return fmt.Errorf("decoding JSON into %T: %s", config, err)
}
if err := json.Unmarshal(data, extra); err != nil {
return fmt.Errorf("decoding JSON into %T: %s", extra, err)
return errs.WithIDf(errIDDecodeJSONIntoExtra, "decoding JSON into %T: %s", extra, err)
}
return nil
}
Expand All @@ -69,7 +81,7 @@ func UnmarshalChainConfigJSON[C any](data []byte, config *ChainConfig, extra *C,
extra,
}
if err := json.Unmarshal(data, &combined); err != nil {
return fmt.Errorf(`decoding JSON into combination of %T and %T (as "extra" key): %s`, config, extra, err)
return errs.WithIDf(errIDDecodeJSONIntoCombination, `decoding JSON into combination of %T and %T (as "extra" key): %s`, config, extra, err)
}
return nil
}
Expand Down Expand Up @@ -100,7 +112,7 @@ func MarshalChainConfigJSON[C any](config ChainConfig, extra C, reuseJSONRoot bo
}
data, err = json.Marshal(jsonExtra)
if err != nil {
return nil, fmt.Errorf(`encoding combination of %T and %T (as "extra" key) to JSON: %s`, config, extra, err)
return nil, errs.WithIDf(errIDEncodeJSONCombination, `encoding combination of %T and %T (as "extra" key) to JSON: %s`, config, extra, err)
}
return data, nil
}
Expand All @@ -116,13 +128,13 @@ func MarshalChainConfigJSON[C any](config ChainConfig, extra C, reuseJSONRoot bo
}
extraJSONRaw, err := toJSONRawMessages(extra)
if err != nil {
return nil, fmt.Errorf("converting extra config to JSON raw messages: %s", err)
return nil, errs.WithIDf(errIDEncodeExtraToRawJSON, "converting extra config to JSON raw messages: %s", err)
}

for k, v := range extraJSONRaw {
_, ok := configJSONRaw[k]
if ok {
return nil, fmt.Errorf("duplicate JSON key %q in ChainConfig and extra %T", k, extra)
return nil, errs.WithIDf(errIDEncodeDuplicateJSONKey, "duplicate JSON key %q in ChainConfig and extra %T", k, extra)
}
configJSONRaw[k] = v
}
Expand Down
63 changes: 20 additions & 43 deletions params/json.libevm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
// You should have received a copy of the GNU Lesser General Public License
// along with the go-ethereum library. If not, see
// <http://www.gnu.org/licenses/>.

package params

import (
Expand All @@ -24,6 +25,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/ava-labs/libevm/internal/libevm/errs"
"github.com/ava-labs/libevm/libevm/pseudo"
)

Expand Down Expand Up @@ -157,42 +159,33 @@ func TestUnmarshalChainConfigJSON_Errors(t *testing.T) {
jsonData string // string for convenience
extra *testExtra
reuseJSONRoot bool
wantConfig ChainConfig
wantExtra any
wantErrRegex string
wantErrID errs.ID
}{
"invalid_json": {
extra: &testExtra{},
wantExtra: &testExtra{},
wantErrRegex: `^decoding JSON into combination of \*.+\.ChainConfig and \*.+\.testExtra \(as "extra" key\): .+$`,
extra: &testExtra{},
wantErrID: errIDDecodeJSONIntoCombination,
},
"nil_extra_at_root_depth": {
jsonData: `{"chainId": 1}`,
extra: nil,
reuseJSONRoot: true,
wantExtra: (*testExtra)(nil),
wantErrRegex: `^\*.+.testExtra argument is nil; use \*.+\.ChainConfig\.UnmarshalJSON\(\) directly$`,
wantErrID: errIDNilExtra,
},
"nil_extra_at_extra_key": {
jsonData: `{"chainId": 1}`,
extra: nil,
wantExtra: (*testExtra)(nil),
wantErrRegex: `^\*.+\.testExtra argument is nil; use \*.+\.ChainConfig.UnmarshalJSON\(\) directly$`,
jsonData: `{"chainId": 1}`,
extra: nil,
wantErrID: errIDNilExtra,
},
"wrong_extra_type_at_extra_key": {
jsonData: `{"chainId": 1, "extra": 1}`,
extra: &testExtra{},
wantConfig: ChainConfig{ChainID: big.NewInt(1)},
wantExtra: &testExtra{},
wantErrRegex: `^decoding JSON into combination of \*.+\.ChainConfig and \*.+\.testExtra \(as "extra" key\): .+$`,
jsonData: `{"chainId": 1, "extra": 1}`,
extra: &testExtra{},
wantErrID: errIDDecodeJSONIntoCombination,
},
"wrong_extra_type_at_root_depth": {
jsonData: `{"chainId": 1, "field": 1}`,
extra: &testExtra{},
reuseJSONRoot: true,
wantConfig: ChainConfig{ChainID: big.NewInt(1)},
wantExtra: &testExtra{},
wantErrRegex: `^decoding JSON into \*.+\.testExtra: .+`,
wantErrID: errIDDecodeJSONIntoExtra,
},
}

Expand All @@ -204,14 +197,7 @@ func TestUnmarshalChainConfigJSON_Errors(t *testing.T) {
data := []byte(testCase.jsonData)
config := ChainConfig{}
err := UnmarshalChainConfigJSON(data, &config, testCase.extra, testCase.reuseJSONRoot)
if testCase.wantErrRegex == "" {
require.NoError(t, err)
} else {
require.Error(t, err)
require.Regexp(t, testCase.wantErrRegex, err.Error())
}
assert.Equal(t, testCase.wantConfig, config)
assert.Equal(t, testCase.wantExtra, testCase.extra)
assert.ErrorIs(t, err, errs.WithID(testCase.wantErrID, ""))
})
}
}
Expand All @@ -223,31 +209,28 @@ func TestMarshalChainConfigJSON_Errors(t *testing.T) {
config ChainConfig
extra any
reuseJSONRoot bool
wantJSONData string // string for convenience
wantErrRegex string
wantErrID errs.ID
}{
"invalid_extra_at_extra_key": {
extra: struct {
Field chan struct{} `json:"field"`
}{},
wantErrRegex: `^encoding combination of .+\.ChainConfig and .+ to JSON: .+$`,
},
"nil_extra_at_extra_key": {
wantJSONData: `{"chainId":null}`,
wantErrID: errIDEncodeJSONCombination,
},
"invalid_extra_at_root_depth": {
extra: struct {
Field chan struct{} `json:"field"`
}{},
reuseJSONRoot: true,
wantErrRegex: "^converting extra config to JSON raw messages: .+$",
wantErrID: errIDEncodeExtraToRawJSON,
},
"duplicate_key": {
extra: struct {
Field string `json:"chainId"`
}{},
reuseJSONRoot: true,
wantErrRegex: `^duplicate JSON key "chainId" in ChainConfig and extra struct .+$`,
wantErrID: errIDEncodeDuplicateJSONKey,
},
}

Expand All @@ -257,14 +240,8 @@ func TestMarshalChainConfigJSON_Errors(t *testing.T) {
t.Parallel()

config := ChainConfig{}
data, err := MarshalChainConfigJSON(config, testCase.extra, testCase.reuseJSONRoot)
if testCase.wantErrRegex == "" {
require.NoError(t, err)
} else {
require.Error(t, err)
assert.Regexp(t, testCase.wantErrRegex, err.Error())
}
assert.Equal(t, testCase.wantJSONData, string(data))
_, err := MarshalChainConfigJSON(config, testCase.extra, testCase.reuseJSONRoot)
assert.ErrorIs(t, err, errs.WithID(testCase.wantErrID, ""))
})
}
}