aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorStefan Majewsky <majewsky@gmx.net>2026-06-18 23:23:36 +0200
committerStefan Majewsky <majewsky@gmx.net>2026-06-19 20:16:11 +0200
commit71d31d73e7dc3b9cdb3c53b4f343b1928a9e858b (patch)
treee1cd6d01837ef6ef07504ba3893c6f5e55f8740b
parentb95ec7449ebec844221f80ecbf7976f3af878eee (diff)
downloadgo-gg-71d31d73e7dc3b9cdb3c53b4f343b1928a9e858b.tar.gz
add package testcapture, package assert (minimal implementation)
-rw-r--r--.golangci.yaml3
-rw-r--r--CHANGELOG.md4
-rw-r--r--README.md2
-rw-r--r--assert/assert.go42
-rw-r--r--assert/equal.go16
-rw-r--r--assert/equal_test.go31
-rw-r--r--assert/errequal.go72
-rw-r--r--assert/errequal_test.go77
-rw-r--r--assetembed/assetembed_test.go36
-rw-r--r--columnar/columnar_test.go10
-rw-r--r--internal/test/helpers.go21
-rw-r--r--is/is_test.go58
-rw-r--r--jsonmatch/diff_test.go56
-rw-r--r--option/option_test.go144
-rw-r--r--options/options_test.go72
-rw-r--r--testcapture/capture.go416
-rw-r--r--testcapture/capture_test.go403
17 files changed, 1253 insertions, 210 deletions
diff --git a/.golangci.yaml b/.golangci.yaml
index 3a37237..12e9cb8 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -70,6 +70,9 @@ linters:
gomoddirectives:
toolchain-forbidden: true
go-version-pattern: 1\.\d+(\.0)?$
+ gosec:
+ excludes:
+ - G306 # gosec does not understand how umask works
nolintlint:
require-specific: true
staticcheck:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 405af16..38b72cc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,9 @@ SPDX-License-Identifier: Apache-2.0
Changes:
-- The minimum Go version was increased from 1.24 to 1.26.
+- Add packages assert and testcapture.
+- The minimum Go version was increased from 1.24 to 1.26
+ because `assert.TestingTB` covers methods added to `testing.TB` in Go 1.26.
# v1.9.1 (2026-06-17)
diff --git a/README.md b/README.md
index bca0263..e840bb5 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,9 @@ My personal extension of the standard library.
### Addons for testing
+- [assert](./assert/): assertions for use in unit tests, plus a testing harness for test assertions like the ones in this package
- [jsonmatch](./jsonmatch/): matching of encoded JSON payloads against fixed assertions
+- [testcapture](./testcapture/): execute test code in a way that captures error messages and side effects without failing the overall test (e.g. to unit-test test assertions themselves)
## How to contribute
diff --git a/assert/assert.go b/assert/assert.go
new file mode 100644
index 0000000..a13c15e
--- /dev/null
+++ b/assert/assert.go
@@ -0,0 +1,42 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+// Package assert contains assertions for use in unit tests.
+// Each assertion in this package returns a bool to indicate whether the check succeeded, and logs a t.Error() when the check does not succeed.
+package assert
+
+import (
+ "context"
+ "io"
+ "testing"
+)
+
+// TestingTB contains all the public functions of [testing.TB] (as of Go 1.26).
+// Functions in this package use this type instead of [testing.TB] because the capture device used by package testcapture cannot implement [testing.TB]: It contains methods that are private to the standard library.
+type TestingTB interface {
+ ArtifactDir() string
+ Attr(key, value string)
+ Chdir(dir string)
+ Cleanup(func())
+ Context() context.Context
+ Error(args ...any)
+ Errorf(format string, args ...any)
+ Fail()
+ Failed() bool
+ FailNow()
+ Fatal(args ...any)
+ Fatalf(format string, args ...any)
+ Helper()
+ Log(args ...any)
+ Logf(format string, args ...any)
+ Name() string
+ Output() io.Writer
+ Setenv(key, value string)
+ Skip(args ...any)
+ Skipf(format string, args ...any)
+ SkipNow()
+ Skipped() bool
+ TempDir() string
+}
+
+var _ TestingTB = testing.TB(nil)
diff --git a/assert/equal.go b/assert/equal.go
new file mode 100644
index 0000000..1222ecf
--- /dev/null
+++ b/assert/equal.go
@@ -0,0 +1,16 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package assert
+
+import "reflect"
+
+// Equal checks whether both supplied values are equal according to the rules of [reflect.DeepEqual].
+func Equal[V any](t TestingTB, actual, expected V) bool {
+ if reflect.DeepEqual(actual, expected) {
+ return true
+ }
+ t.Helper()
+ t.Errorf("expected %#v, but got %#v", expected, actual)
+ return false
+}
diff --git a/assert/equal_test.go b/assert/equal_test.go
new file mode 100644
index 0000000..c710cea
--- /dev/null
+++ b/assert/equal_test.go
@@ -0,0 +1,31 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package assert_test
+
+import (
+ "strings"
+ "testing"
+
+ "go.xyrillian.de/gg/assert"
+ "go.xyrillian.de/gg/testcapture"
+)
+
+func expectErrors(t *testing.T, test func(assert.TestingTB), expected string) {
+ t.Helper()
+ r := testcapture.Result{Outcome: testcapture.OutcomeFailed}
+ for line := range strings.SplitSeq(expected, "\n") {
+ line = strings.TrimSpace(line)
+ if line != "" {
+ r.Messages = append(r.Messages, testcapture.Log(line))
+ }
+ }
+ assert.Equal(t, testcapture.Capture(t.Context(), t.Name(), test), r)
+}
+
+func TestEqual(t *testing.T) {
+ assert.Equal(t, true, true)
+ expectErrors(t, func(t assert.TestingTB) {
+ assert.Equal(t, false, true)
+ }, `expected true, but got false`)
+}
diff --git a/assert/errequal.go b/assert/errequal.go
new file mode 100644
index 0000000..4752f26
--- /dev/null
+++ b/assert/errequal.go
@@ -0,0 +1,72 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package assert
+
+import (
+ "errors"
+ "fmt"
+ "reflect"
+ "regexp"
+)
+
+// ErrEqual checks if the actual error matches the expectation.
+// - If expected is nil, the actual error must be nil.
+// - If expected is of type error, the actual error must be exactly equal to it or contain it, as reported by the [errors.Is] function.
+// - If expected is of type string, the actual error must have a message exactly equal to it.
+// - If expected is of type [*regexp.Regexp], the actual error must have a message matching that regexp.
+// - If expected is of any other type, ErrEqual will panic.
+func ErrEqual(t TestingTB, actual error, expected any) bool {
+ // coerce all types that implement `error` into the interface type `error`,
+ // and also coerce all nil values of concrete `error` types into untyped nil
+ if expectedErr, ok := expected.(error); ok {
+ // convert nil values of concrete error types into a generic nil value
+ if reflect.ValueOf(expectedErr).IsNil() {
+ expected = nil
+ } else {
+ expected = expectedErr
+ }
+ }
+
+ switch expected := expected.(type) {
+ case nil:
+ if actual == nil {
+ return true
+ } else {
+ t.Errorf("expected no error, but got %q", actual.Error())
+ return false
+ }
+ case error:
+ if actual == nil {
+ t.Errorf("expected %q, but got no error", expected.Error())
+ return false
+ } else if errors.Is(actual, expected) {
+ return true
+ } else {
+ t.Errorf("expected %q, but got %q", expected.Error(), actual.Error())
+ return false
+ }
+ case string:
+ if actual == nil {
+ t.Errorf("expected %q, but got no error", expected)
+ return false
+ } else if actual.Error() == expected {
+ return true
+ } else {
+ t.Errorf("expected %q, but got %q", expected, actual.Error())
+ return false
+ }
+ case *regexp.Regexp:
+ if actual == nil {
+ t.Errorf("expected an error matching /%s/, but got no error", expected.String())
+ return false
+ } else if expected.MatchString(actual.Error()) {
+ return true
+ } else {
+ t.Errorf("expected an error matching /%s/, but got %q", expected.String(), actual.Error())
+ return false
+ }
+ default:
+ panic(fmt.Sprintf("cannot handle `expected` of type %T", expected))
+ }
+}
diff --git a/assert/errequal_test.go b/assert/errequal_test.go
new file mode 100644
index 0000000..5efbdc5
--- /dev/null
+++ b/assert/errequal_test.go
@@ -0,0 +1,77 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package assert_test
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "regexp"
+ "testing"
+
+ "go.xyrillian.de/gg/assert"
+ "go.xyrillian.de/gg/testcapture"
+)
+
+func TestErrEqual(t *testing.T) {
+ noError := error(nil)
+ fooError := errors.New("foo error")
+ barError := errors.New("bar error")
+ nestedFooError := fmt.Errorf("nested error: %w", fooError)
+
+ // test matching against nil
+ assert.ErrEqual(t, noError, nil)
+ assert.ErrEqual(t, noError, (*os.PathError)(nil))
+ expectErrors(t, func(t assert.TestingTB) {
+ assert.ErrEqual(t, fooError, nil)
+ }, `expected no error, but got "foo error"`)
+ expectErrors(t, func(t assert.TestingTB) {
+ assert.ErrEqual(t, fooError, (*os.PathError)(nil))
+ }, `expected no error, but got "foo error"`)
+
+ // test matching against error
+ assert.ErrEqual(t, fooError, fooError)
+ assert.ErrEqual(t, nestedFooError, fooError)
+ expectErrors(t, func(t assert.TestingTB) {
+ assert.ErrEqual(t, fooError, nestedFooError) // error nesting does not work the other way, we need to see the full `expected`error in`actual`
+ }, `expected "nested error: foo error", but got "foo error"`)
+ expectErrors(t, func(t assert.TestingTB) {
+ assert.ErrEqual(t, barError, fooError) // check with unrelated errors for completeness
+ }, `expected "foo error", but got "bar error"`)
+ expectErrors(t, func(t assert.TestingTB) {
+ assert.ErrEqual(t, nil, fooError)
+ }, `expected "foo error", but got no error`)
+
+ // test matching against string
+ assert.ErrEqual(t, fooError, "foo error")
+ expectErrors(t, func(t assert.TestingTB) {
+ assert.ErrEqual(t, nestedFooError, "foo error") // partial matches do not work
+ }, `expected "foo error", but got "nested error: foo error"`)
+ expectErrors(t, func(t assert.TestingTB) {
+ assert.ErrEqual(t, barError, "foo error") // check with unrelated errors for completeness
+ }, `expected "foo error", but got "bar error"`)
+ expectErrors(t, func(t assert.TestingTB) {
+ assert.ErrEqual(t, nil, "foo error")
+ }, `expected "foo error", but got no error`)
+
+ // test matching against regexp
+ fooRegexp := regexp.MustCompile(`foo`)
+ assert.ErrEqual(t, fooError, fooRegexp) // partial matches allowed here as long as regexp does not use ^ and $
+ assert.ErrEqual(t, nestedFooError, fooRegexp)
+ expectErrors(t, func(t assert.TestingTB) {
+ assert.ErrEqual(t, barError, fooRegexp) // check with unrelated errors for completeness
+ }, `expected an error matching /foo/, but got "bar error"`)
+ expectErrors(t, func(t assert.TestingTB) {
+ assert.ErrEqual(t, nil, fooRegexp)
+ }, `expected an error matching /foo/, but got no error`)
+
+ // test matching against unexpected type
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ assert.ErrEqual(t, errors.New("42"), 42)
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomePanicked,
+ Panic: "cannot handle `expected` of type int",
+ })
+}
diff --git a/assetembed/assetembed_test.go b/assetembed/assetembed_test.go
index 916d4dc..b5bea37 100644
--- a/assetembed/assetembed_test.go
+++ b/assetembed/assetembed_test.go
@@ -13,8 +13,8 @@ import (
"testing/fstest"
"time"
+ "go.xyrillian.de/gg/assert"
"go.xyrillian.de/gg/assetembed"
- . "go.xyrillian.de/gg/internal/test"
. "go.xyrillian.de/gg/option"
)
@@ -48,15 +48,15 @@ func TestAssetembed(t *testing.T) {
return base64str
}
fooTxtPath := fmt.Sprintf("foo-%s.txt", urlSafe(fooSHA384))
- AssertEqual(t, h.AssetPath("foo.txt"), Some(fooTxtPath))
+ assert.Equal(t, h.AssetPath("foo.txt"), Some(fooTxtPath))
barJSPath := fmt.Sprintf("res/assets/bar-%s.js", urlSafe(barSHA384))
- AssertEqual(t, h.AssetPath("res/assets/bar.js"), Some(barJSPath))
- AssertEqual(t, h.AssetPath("res/assets/unknown.css"), None[string]())
+ assert.Equal(t, h.AssetPath("res/assets/bar.js"), Some(barJSPath))
+ assert.Equal(t, h.AssetPath("res/assets/unknown.css"), None[string]())
// test Handler.SubresourceIntegrity()
- AssertEqual(t, h.SubresourceIntegrity("foo.txt"), Some("sha384-"+fooSHA384))
- AssertEqual(t, h.SubresourceIntegrity("res/assets/bar.js"), Some("sha384-"+barSHA384))
- AssertEqual(t, h.SubresourceIntegrity("res/assets/unknown.css"), None[string]())
+ assert.Equal(t, h.SubresourceIntegrity("foo.txt"), Some("sha384-"+fooSHA384))
+ assert.Equal(t, h.SubresourceIntegrity("res/assets/bar.js"), Some("sha384-"+barSHA384))
+ assert.Equal(t, h.SubresourceIntegrity("res/assets/unknown.css"), None[string]())
// test HTTP handler
for _, method := range []string{http.MethodHead, http.MethodGet} {
@@ -76,26 +76,26 @@ func TestAssetembed(t *testing.T) {
}
resp := probe("/" + fooTxtPath)
- AssertEqual(t, resp.Code, http.StatusOK)
- AssertEqual(t, resp.Header().Get("Content-Type"), "text/plain; charset=utf-8")
- AssertEqual(t, resp.Header().Get("Content-Length"), strconv.Itoa(len(fooTxt)))
- AssertEqual(t, resp.Body.String(), ifGet(fooTxt))
+ assert.Equal(t, resp.Code, http.StatusOK)
+ assert.Equal(t, resp.Header().Get("Content-Type"), "text/plain; charset=utf-8")
+ assert.Equal(t, resp.Header().Get("Content-Length"), strconv.Itoa(len(fooTxt)))
+ assert.Equal(t, resp.Body.String(), ifGet(fooTxt))
resp = probe("/" + barJSPath)
- AssertEqual(t, resp.Code, http.StatusOK)
- AssertEqual(t, resp.Header().Get("Content-Type"), "text/javascript; charset=utf-8")
- AssertEqual(t, resp.Header().Get("Content-Length"), strconv.Itoa(len(barJS)))
- AssertEqual(t, resp.Body.String(), ifGet(barJS))
+ assert.Equal(t, resp.Code, http.StatusOK)
+ assert.Equal(t, resp.Header().Get("Content-Type"), "text/javascript; charset=utf-8")
+ assert.Equal(t, resp.Header().Get("Content-Length"), strconv.Itoa(len(barJS)))
+ assert.Equal(t, resp.Body.String(), ifGet(barJS))
resp = probe("/foo.txt") // without digest!
- AssertEqual(t, resp.Code, http.StatusNotFound)
+ assert.Equal(t, resp.Code, http.StatusNotFound)
resp = probe("/res/assets/unknown.css") // entirely unknown file
- AssertEqual(t, resp.Code, http.StatusNotFound)
+ assert.Equal(t, resp.Code, http.StatusNotFound)
}
req := httptest.NewRequest(http.MethodPut, "/"+fooTxtPath, http.NoBody)
resp := httptest.NewRecorder()
h.ServeHTTP(resp, req)
- AssertEqual(t, resp.Code, http.StatusMethodNotAllowed)
+ assert.Equal(t, resp.Code, http.StatusMethodNotAllowed)
}
diff --git a/columnar/columnar_test.go b/columnar/columnar_test.go
index 3e050ce..2804ac9 100644
--- a/columnar/columnar_test.go
+++ b/columnar/columnar_test.go
@@ -8,8 +8,8 @@ import (
"sync"
"testing"
+ "go.xyrillian.de/gg/assert"
"go.xyrillian.de/gg/columnar"
- . "go.xyrillian.de/gg/internal/test"
"go.xyrillian.de/gg/jsonmatch"
)
@@ -27,7 +27,7 @@ func testSuccessfulJSONRoundtrip[V any](t *testing.T, list []V, encoded jsonmatc
if err != nil {
t.Fatal(err)
}
- AssertEqual(t, []V(unmarshaled), list)
+ assert.Equal(t, []V(unmarshaled), list)
}
func TestJSONRoundtripBasic(t *testing.T) {
@@ -102,9 +102,9 @@ func TestJSONRoundtripErrors(t *testing.T) {
}
_, err := json.Marshal(columnar.List[nothingPublic]{{1, 2}})
- AssertEqual(t, err.Error(), `json: error calling MarshalJSON for type columnar.List[go.xyrillian.de/gg/columnar_test.nothingPublic·5]: columnar_test.nothingPublic has no exported fields`)
+ assert.Equal(t, err.Error(), `json: error calling MarshalJSON for type columnar.List[go.xyrillian.de/gg/columnar_test.nothingPublic·5]: columnar_test.nothingPublic has no exported fields`)
err = json.Unmarshal([]byte(`{}`), new(columnar.List[nothingPublic]{}))
- AssertEqual(t, err.Error(), `columnar_test.nothingPublic has no exported fields`)
+ assert.Equal(t, err.Error(), `columnar_test.nothingPublic has no exported fields`)
}
func TestJSONUnmarshalFromInconsistentLengths(t *testing.T) {
@@ -114,7 +114,7 @@ func TestJSONUnmarshalFromInconsistentLengths(t *testing.T) {
}
err := json.Unmarshal([]byte(`{"Foo":[1,2],"Bar":[3,4,5]}`), new(columnar.List[Record]{}))
- AssertEqual(t, err.Error(), `cannot unmarshal from columns with inconsistent lengths [2 3]`)
+ assert.Equal(t, err.Error(), `cannot unmarshal from columns with inconsistent lengths [2 3]`)
}
func TestIgnoredFields(t *testing.T) {
diff --git a/internal/test/helpers.go b/internal/test/helpers.go
deleted file mode 100644
index 4cfca1e..0000000
--- a/internal/test/helpers.go
+++ /dev/null
@@ -1,21 +0,0 @@
-/*******************************************************************************
-* Copyright 2025 Stefan Majewsky <majewsky@gmx.net>
-* SPDX-License-Identifier: Apache-2.0
-* Refer to the file "LICENSE" for details.
-*******************************************************************************/
-
-package test
-
-import (
- "reflect"
- "testing"
-)
-
-func AssertEqual[V any](t *testing.T, actual, expected V) bool {
- if reflect.DeepEqual(actual, expected) {
- return true
- }
- t.Helper()
- t.Errorf("expected %#v, but got %#v", expected, actual)
- return false
-}
diff --git a/is/is_test.go b/is/is_test.go
index 854fd48..a0b51cf 100644
--- a/is/is_test.go
+++ b/is/is_test.go
@@ -7,35 +7,35 @@ import (
"testing"
"time"
- . "go.xyrillian.de/gg/internal/test"
+ "go.xyrillian.de/gg/assert"
"go.xyrillian.de/gg/is"
. "go.xyrillian.de/gg/option"
)
func TestComparable(t *testing.T) {
- AssertEqual(t, Some("foo").IsSomeAnd(is.EqualTo("foo")), true)
- AssertEqual(t, Some("bar").IsSomeAnd(is.EqualTo("foo")), false)
+ assert.Equal(t, Some("foo").IsSomeAnd(is.EqualTo("foo")), true)
+ assert.Equal(t, Some("bar").IsSomeAnd(is.EqualTo("foo")), false)
- AssertEqual(t, Some("foo").IsSomeAnd(is.DifferentFrom("foo")), false)
- AssertEqual(t, Some("bar").IsSomeAnd(is.DifferentFrom("foo")), true)
+ assert.Equal(t, Some("foo").IsSomeAnd(is.DifferentFrom("foo")), false)
+ assert.Equal(t, Some("bar").IsSomeAnd(is.DifferentFrom("foo")), true)
}
func TestOrdered(t *testing.T) {
- AssertEqual(t, Some(3).IsSomeAnd(is.Above(4)), false)
- AssertEqual(t, Some(4).IsSomeAnd(is.Above(4)), false)
- AssertEqual(t, Some(5).IsSomeAnd(is.Above(4)), true)
+ assert.Equal(t, Some(3).IsSomeAnd(is.Above(4)), false)
+ assert.Equal(t, Some(4).IsSomeAnd(is.Above(4)), false)
+ assert.Equal(t, Some(5).IsSomeAnd(is.Above(4)), true)
- AssertEqual(t, Some(3).IsSomeAnd(is.Below(4)), true)
- AssertEqual(t, Some(4).IsSomeAnd(is.Below(4)), false)
- AssertEqual(t, Some(5).IsSomeAnd(is.Below(4)), false)
+ assert.Equal(t, Some(3).IsSomeAnd(is.Below(4)), true)
+ assert.Equal(t, Some(4).IsSomeAnd(is.Below(4)), false)
+ assert.Equal(t, Some(5).IsSomeAnd(is.Below(4)), false)
- AssertEqual(t, Some(3).IsSomeAnd(is.NotAbove(4)), true)
- AssertEqual(t, Some(4).IsSomeAnd(is.NotAbove(4)), true)
- AssertEqual(t, Some(5).IsSomeAnd(is.NotAbove(4)), false)
+ assert.Equal(t, Some(3).IsSomeAnd(is.NotAbove(4)), true)
+ assert.Equal(t, Some(4).IsSomeAnd(is.NotAbove(4)), true)
+ assert.Equal(t, Some(5).IsSomeAnd(is.NotAbove(4)), false)
- AssertEqual(t, Some(3).IsSomeAnd(is.NotBelow(4)), false)
- AssertEqual(t, Some(4).IsSomeAnd(is.NotBelow(4)), true)
- AssertEqual(t, Some(5).IsSomeAnd(is.NotBelow(4)), true)
+ assert.Equal(t, Some(3).IsSomeAnd(is.NotBelow(4)), false)
+ assert.Equal(t, Some(4).IsSomeAnd(is.NotBelow(4)), true)
+ assert.Equal(t, Some(5).IsSomeAnd(is.NotBelow(4)), true)
}
func TestTime(t *testing.T) {
@@ -43,19 +43,19 @@ func TestTime(t *testing.T) {
t2 := t1.Add(time.Second)
t3 := t2.Add(time.Second)
- AssertEqual(t, Some(t1).IsSomeAnd(is.After(t2)), false)
- AssertEqual(t, Some(t2).IsSomeAnd(is.After(t2)), false)
- AssertEqual(t, Some(t3).IsSomeAnd(is.After(t2)), true)
+ assert.Equal(t, Some(t1).IsSomeAnd(is.After(t2)), false)
+ assert.Equal(t, Some(t2).IsSomeAnd(is.After(t2)), false)
+ assert.Equal(t, Some(t3).IsSomeAnd(is.After(t2)), true)
- AssertEqual(t, Some(t1).IsSomeAnd(is.Before(t2)), true)
- AssertEqual(t, Some(t2).IsSomeAnd(is.Before(t2)), false)
- AssertEqual(t, Some(t3).IsSomeAnd(is.Before(t2)), false)
+ assert.Equal(t, Some(t1).IsSomeAnd(is.Before(t2)), true)
+ assert.Equal(t, Some(t2).IsSomeAnd(is.Before(t2)), false)
+ assert.Equal(t, Some(t3).IsSomeAnd(is.Before(t2)), false)
- AssertEqual(t, Some(t1).IsSomeAnd(is.NotAfter(t2)), true)
- AssertEqual(t, Some(t2).IsSomeAnd(is.NotAfter(t2)), true)
- AssertEqual(t, Some(t3).IsSomeAnd(is.NotAfter(t2)), false)
+ assert.Equal(t, Some(t1).IsSomeAnd(is.NotAfter(t2)), true)
+ assert.Equal(t, Some(t2).IsSomeAnd(is.NotAfter(t2)), true)
+ assert.Equal(t, Some(t3).IsSomeAnd(is.NotAfter(t2)), false)
- AssertEqual(t, Some(t1).IsSomeAnd(is.NotBefore(t2)), false)
- AssertEqual(t, Some(t2).IsSomeAnd(is.NotBefore(t2)), true)
- AssertEqual(t, Some(t3).IsSomeAnd(is.NotBefore(t2)), true)
+ assert.Equal(t, Some(t1).IsSomeAnd(is.NotBefore(t2)), false)
+ assert.Equal(t, Some(t2).IsSomeAnd(is.NotBefore(t2)), true)
+ assert.Equal(t, Some(t3).IsSomeAnd(is.NotBefore(t2)), true)
}
diff --git a/jsonmatch/diff_test.go b/jsonmatch/diff_test.go
index aba2d00..d81c54b 100644
--- a/jsonmatch/diff_test.go
+++ b/jsonmatch/diff_test.go
@@ -10,7 +10,7 @@ import (
"strings"
"testing"
- . "go.xyrillian.de/gg/internal/test"
+ "go.xyrillian.de/gg/assert"
"go.xyrillian.de/gg/jsonmatch"
. "go.xyrillian.de/gg/option"
)
@@ -59,7 +59,7 @@ func TestCanonicalizesActualPayload(t *testing.T) {
"qux": jsonmatch.Array{5, nil, 15},
},
}
- AssertEqual(t, match.DiffAgainst(message), nil)
+ assert.Equal(t, match.DiffAgainst(message), nil)
// changing the type of `data` to map[string]any and of `data.qux` to []any does not change anything at all;
// using the jsonmatch.Object and jsonmatch.Array names on this level is mostly syntactic sugar to communicate intent;
@@ -72,7 +72,7 @@ func TestCanonicalizesActualPayload(t *testing.T) {
"qux": []any{5, nil, 15},
},
}
- AssertEqual(t, match.DiffAgainst(message), nil)
+ assert.Equal(t, match.DiffAgainst(message), nil)
// this is using subtypes that our logic cannot recurse into
// (map[opaqueString]any instead of map[string]any and []Option[int] instead of []any);
@@ -85,7 +85,7 @@ func TestCanonicalizesActualPayload(t *testing.T) {
"qux": []Option[int]{Some(5), None[int](), Some(15)},
},
}
- AssertEqual(t, match.DiffAgainst(message), nil)
+ assert.Equal(t, match.DiffAgainst(message), nil)
// this is using a specific struct type instead of a map[string]any, which results in a different serialization
// (map[string]any serializes with keys sorted alphabetically, but structs serialize with keys sorted by field declaration order;
@@ -101,14 +101,14 @@ func TestCanonicalizesActualPayload(t *testing.T) {
Qux: []Option[int]{Some(5), None[int](), Some(15)},
},
}
- AssertEqual(t, match.DiffAgainst(message), nil)
+ assert.Equal(t, match.DiffAgainst(message), nil)
// to try and trip up the normalization shown above, this match deliberately contains an unmarshalable object;
// jsonmatch should recognize that marshaling and unmarshaling does not work and skip the normalization
match = jsonmatch.Object{
"data": unmarshalableObject{},
}
- AssertEqual(t, match.DiffAgainst(message), []jsonmatch.Diff{{
+ assert.Equal(t, match.DiffAgainst(message), []jsonmatch.Diff{{
Kind: "type mismatch",
Pointer: "/data",
ExpectedJSON: `<not marshalable to JSON, %#v is jsonmatch_test.unmarshalableObject{}>`,
@@ -144,10 +144,10 @@ func TestCapturesFields(t *testing.T) {
},
}
- AssertEqual(t, match.DiffAgainst(message), nil)
- AssertEqual(t, capturedUUID1, uuid1)
- AssertEqual(t, capturedUUID2, uuid2)
- AssertEqual(t, capturedTag1, "bar")
+ assert.Equal(t, match.DiffAgainst(message), nil)
+ assert.Equal(t, capturedUUID1, uuid1)
+ assert.Equal(t, capturedUUID2, uuid2)
+ assert.Equal(t, capturedTag1, "bar")
// check that CaptureField() complains when unmarshaling JSON messages into incompatible types
var (
@@ -166,7 +166,7 @@ func TestCapturesFields(t *testing.T) {
},
}
- AssertEqual(t, match.DiffAgainst(message), []jsonmatch.Diff{{
+ assert.Equal(t, match.DiffAgainst(message), []jsonmatch.Diff{{
Kind: "cannot unmarshal into capture slot (json: cannot unmarshal string into Go value of type int)",
Pointer: "/objects/0/id",
ExpectedJSON: "<capture slot of type *int>",
@@ -198,7 +198,7 @@ func TestCapturesFields(t *testing.T) {
},
}
- AssertEqual(t, match.DiffAgainst(message), []jsonmatch.Diff{{
+ assert.Equal(t, match.DiffAgainst(message), []jsonmatch.Diff{{
Kind: "value mismatch",
Pointer: "/objects",
ActualJSON: fmt.Sprintf(`[{"id":"%s","tags":["foo"]},{"id":"%s","tags":["bar"]}]`, uuid1, uuid2),
@@ -227,7 +227,7 @@ func TestFailsOnValueMismatch(t *testing.T) {
},
}
- AssertEqual(t, match.DiffAgainst(message), []jsonmatch.Diff{
+ assert.Equal(t, match.DiffAgainst(message), []jsonmatch.Diff{
{
Kind: "value mismatch",
Pointer: "/users/0/name",
@@ -315,10 +315,10 @@ func TestFailsOnTypeMismatch(t *testing.T) {
objectMatch := jsonmatch.Object{"payload": tc2.Data}
if idx1 == idx2 {
// if we chose matching JSON and data types, then everything works as intended
- AssertEqual(t, objectMatch.DiffAgainst(objectMessage), nil)
+ assert.Equal(t, objectMatch.DiffAgainst(objectMessage), nil)
} else {
// otherwise we expect a "type mismatch" error
- AssertEqual(t, objectMatch.DiffAgainst(objectMessage), []jsonmatch.Diff{{
+ assert.Equal(t, objectMatch.DiffAgainst(objectMessage), []jsonmatch.Diff{{
Kind: "type mismatch",
Pointer: "/payload",
ActualJSON: tc1.JSON,
@@ -329,9 +329,9 @@ func TestFailsOnTypeMismatch(t *testing.T) {
// type mismatch inside of an array
arrayMatch := jsonmatch.Array{1, tc2.Data}
if idx1 == idx2 {
- AssertEqual(t, arrayMatch.DiffAgainst(arrayMessage), nil)
+ assert.Equal(t, arrayMatch.DiffAgainst(arrayMessage), nil)
} else {
- AssertEqual(t, arrayMatch.DiffAgainst(arrayMessage), []jsonmatch.Diff{{
+ assert.Equal(t, arrayMatch.DiffAgainst(arrayMessage), []jsonmatch.Diff{{
Kind: "type mismatch",
Pointer: "/1",
ActualJSON: tc1.JSON,
@@ -342,9 +342,9 @@ func TestFailsOnTypeMismatch(t *testing.T) {
// type mismatch for plain scalar
if scalarMatch, ok := tc2.Scalar.Unpack(); ok {
if idx1 == idx2 {
- AssertEqual(t, scalarMatch.DiffAgainst(plainMessage), nil)
+ assert.Equal(t, scalarMatch.DiffAgainst(plainMessage), nil)
} else {
- AssertEqual(t, scalarMatch.DiffAgainst(plainMessage), []jsonmatch.Diff{{
+ assert.Equal(t, scalarMatch.DiffAgainst(plainMessage), []jsonmatch.Diff{{
Kind: "type mismatch",
Pointer: "",
ActualJSON: tc1.JSON,
@@ -376,13 +376,13 @@ func TestFailsOnUnmarshalError(t *testing.T) {
for _, message := range testCases {
diffs := match.DiffAgainst(message)
- if AssertEqual(t, len(diffs), 1) {
+ if assert.Equal(t, len(diffs), 1) {
diff := diffs[0]
- AssertEqual(t, strings.HasPrefix(diff.Kind, "unmarshal error ("), true)
- AssertEqual(t, strings.HasSuffix(diff.Kind, ")"), true)
- AssertEqual(t, diff.Pointer, "")
- AssertEqual(t, diff.ExpectedJSON, `{"data":[23,42]}`)
- AssertEqual(t, strings.ReplaceAll(diff.ActualJSON, "\uFFFD", ""), strings.ToValidUTF8(string(message), ""))
+ assert.Equal(t, strings.HasPrefix(diff.Kind, "unmarshal error ("), true)
+ assert.Equal(t, strings.HasSuffix(diff.Kind, ")"), true)
+ assert.Equal(t, diff.Pointer, "")
+ assert.Equal(t, diff.ExpectedJSON, `{"data":[23,42]}`)
+ assert.Equal(t, strings.ReplaceAll(diff.ActualJSON, "\uFFFD", ""), strings.ToValidUTF8(string(message), ""))
}
}
}
@@ -403,11 +403,11 @@ func TestArrayOnArrayAction(t *testing.T) {
}}
match := jsonmatch.Array{[]any{1, 2}}
- AssertEqual(t, match.DiffAgainst(message), expected)
+ assert.Equal(t, match.DiffAgainst(message), expected)
// this used to fail before the fix in the commit that added this test
match = jsonmatch.Array{jsonmatch.Array{1, 2}}
- AssertEqual(t, match.DiffAgainst(message), expected)
+ assert.Equal(t, match.DiffAgainst(message), expected)
}
func TestDispatchIntoCustomDiffable(t *testing.T) {
@@ -426,7 +426,7 @@ func TestDispatchIntoCustomDiffable(t *testing.T) {
ExpectedJSON: "2",
ActualJSON: "3",
}}
- AssertEqual(t, match.DiffAgainst(message), expected)
+ assert.Equal(t, match.DiffAgainst(message), expected)
}
// jsonWithinJSONString appears in TestDispatchIntoCustomDiffable.
diff --git a/option/option_test.go b/option/option_test.go
index 646108a..7ba50d9 100644
--- a/option/option_test.go
+++ b/option/option_test.go
@@ -9,25 +9,25 @@ import (
"slices"
"testing"
- . "go.xyrillian.de/gg/internal/test"
+ "go.xyrillian.de/gg/assert"
)
func TestZeroValue(t *testing.T) {
var zero Option[string]
- AssertEqual(t, zero, None[string]())
+ assert.Equal(t, zero, None[string]())
}
////////////////////////////////////////////////////////////////////////////////
// core API (methods sorted by name)
func TestAsPointer(t *testing.T) {
- AssertEqual(t, None[int]().AsPointer(), nil)
- AssertEqual(t, Some(42).AsPointer(), new(42))
+ assert.Equal(t, None[int]().AsPointer(), nil)
+ assert.Equal(t, Some(42).AsPointer(), new(42))
}
func TestAsSlice(t *testing.T) {
- AssertEqual(t, None[int]().AsSlice(), []int(nil))
- AssertEqual(t, Some(42).AsSlice(), []int{42})
+ assert.Equal(t, None[int]().AsSlice(), []int(nil))
+ assert.Equal(t, Some(42).AsSlice(), []int{42})
}
func isEven(x int) bool {
@@ -36,49 +36,49 @@ func isEven(x int) bool {
}
func TestFilter(t *testing.T) {
- AssertEqual(t, None[int]().Filter(isEven), None[int]())
- AssertEqual(t, Some(41).Filter(isEven), None[int]())
- AssertEqual(t, Some(42).Filter(isEven), Some(42))
+ assert.Equal(t, None[int]().Filter(isEven), None[int]())
+ assert.Equal(t, Some(41).Filter(isEven), None[int]())
+ assert.Equal(t, Some(42).Filter(isEven), Some(42))
}
func TestIsNone(t *testing.T) {
- AssertEqual(t, None[int]().IsNone(), true)
- AssertEqual(t, Some(42).IsNone(), false)
+ assert.Equal(t, None[int]().IsNone(), true)
+ assert.Equal(t, Some(42).IsNone(), false)
}
func TestIsNoneOr(t *testing.T) {
- AssertEqual(t, None[int]().IsNoneOr(isEven), true)
- AssertEqual(t, Some(41).IsNoneOr(isEven), false)
- AssertEqual(t, Some(42).IsNoneOr(isEven), true)
+ assert.Equal(t, None[int]().IsNoneOr(isEven), true)
+ assert.Equal(t, Some(41).IsNoneOr(isEven), false)
+ assert.Equal(t, Some(42).IsNoneOr(isEven), true)
}
func TestIsSome(t *testing.T) {
- AssertEqual(t, None[int]().IsSome(), false)
- AssertEqual(t, Some(42).IsSome(), true)
+ assert.Equal(t, None[int]().IsSome(), false)
+ assert.Equal(t, Some(42).IsSome(), true)
}
func TestIsSomeAnd(t *testing.T) {
- AssertEqual(t, None[int]().IsSomeAnd(isEven), false)
- AssertEqual(t, Some(41).IsSomeAnd(isEven), false)
- AssertEqual(t, Some(42).IsSomeAnd(isEven), true)
+ assert.Equal(t, None[int]().IsSomeAnd(isEven), false)
+ assert.Equal(t, Some(41).IsSomeAnd(isEven), false)
+ assert.Equal(t, Some(42).IsSomeAnd(isEven), true)
}
func TestIsZero(t *testing.T) {
- AssertEqual(t, None[int]().IsZero(), true)
- AssertEqual(t, Some(42).IsZero(), false)
+ assert.Equal(t, None[int]().IsZero(), true)
+ assert.Equal(t, Some(42).IsZero(), false)
}
func TestIter(t *testing.T) {
- AssertEqual(t, slices.Collect(None[int]().Iter()), []int(nil))
- AssertEqual(t, slices.Collect(Some(42).Iter()), []int{42})
+ assert.Equal(t, slices.Collect(None[int]().Iter()), []int(nil))
+ assert.Equal(t, slices.Collect(Some(42).Iter()), []int{42})
}
func TestOr(t *testing.T) {
none := None[int]()
- AssertEqual(t, none.Or(none), None[int]())
- AssertEqual(t, Some(42).Or(none), Some(42))
- AssertEqual(t, none.Or(Some(43)), Some(43))
- AssertEqual(t, Some(42).Or(Some(43)), Some(42))
+ assert.Equal(t, none.Or(none), None[int]())
+ assert.Equal(t, Some(42).Or(none), Some(42))
+ assert.Equal(t, none.Or(Some(43)), Some(43))
+ assert.Equal(t, Some(42).Or(Some(43)), Some(42))
}
func TestOrElse(t *testing.T) {
@@ -92,29 +92,29 @@ func TestOrElse(t *testing.T) {
return Some(43)
}
- AssertEqual(t, None[int]().OrElse(makeNone), None[int]())
- AssertEqual(t, callCount, 1)
- AssertEqual(t, Some(42).OrElse(makeNone), Some(42))
- AssertEqual(t, callCount, 1)
- AssertEqual(t, None[int]().OrElse(makeSome), Some(43))
- AssertEqual(t, callCount, 2)
- AssertEqual(t, Some(42).OrElse(makeSome), Some(42))
- AssertEqual(t, callCount, 2)
+ assert.Equal(t, None[int]().OrElse(makeNone), None[int]())
+ assert.Equal(t, callCount, 1)
+ assert.Equal(t, Some(42).OrElse(makeNone), Some(42))
+ assert.Equal(t, callCount, 1)
+ assert.Equal(t, None[int]().OrElse(makeSome), Some(43))
+ assert.Equal(t, callCount, 2)
+ assert.Equal(t, Some(42).OrElse(makeSome), Some(42))
+ assert.Equal(t, callCount, 2)
}
func TestUnpack(t *testing.T) {
val, ok := None[int]().Unpack()
- AssertEqual(t, val, 0)
- AssertEqual(t, ok, false)
+ assert.Equal(t, val, 0)
+ assert.Equal(t, ok, false)
val, ok = Some(42).Unpack()
- AssertEqual(t, val, 42)
- AssertEqual(t, ok, true)
+ assert.Equal(t, val, 42)
+ assert.Equal(t, ok, true)
}
func TestUnwrapOr(t *testing.T) {
- AssertEqual(t, None[int]().UnwrapOr(5), 5)
- AssertEqual(t, Some(42).UnwrapOr(5), 42)
+ assert.Equal(t, None[int]().UnwrapOr(5), 5)
+ assert.Equal(t, Some(42).UnwrapOr(5), 42)
}
func TestUnwrapOrElse(t *testing.T) {
@@ -124,18 +124,18 @@ func TestUnwrapOrElse(t *testing.T) {
return 5
}
- AssertEqual(t, None[int]().UnwrapOrElse(five), 5)
- AssertEqual(t, callCount, 1)
- AssertEqual(t, Some(42).UnwrapOrElse(five), 42)
- AssertEqual(t, callCount, 1)
+ assert.Equal(t, None[int]().UnwrapOrElse(five), 5)
+ assert.Equal(t, callCount, 1)
+ assert.Equal(t, Some(42).UnwrapOrElse(five), 42)
+ assert.Equal(t, callCount, 1)
}
func TestXor(t *testing.T) {
none := None[int]()
- AssertEqual(t, none.Xor(none), None[int]())
- AssertEqual(t, Some(42).Xor(none), Some(42))
- AssertEqual(t, none.Xor(Some(43)), Some(43))
- AssertEqual(t, Some(42).Xor(Some(43)), None[int]())
+ assert.Equal(t, none.Xor(none), None[int]())
+ assert.Equal(t, Some(42).Xor(none), Some(42))
+ assert.Equal(t, none.Xor(Some(43)), Some(43))
+ assert.Equal(t, Some(42).Xor(Some(43)), None[int]())
}
////////////////////////////////////////////////////////////////////////////////
@@ -145,49 +145,49 @@ func TestFormat(t *testing.T) {
none := None[int]()
some := Some(42)
- AssertEqual(t, fmt.Sprintf("value is %d", none), "value is <none>")
- AssertEqual(t, fmt.Sprintf("value is %d", some), "value is 42")
- AssertEqual(t, fmt.Sprintf("value is %010d", none), "value is 0000<none>")
- AssertEqual(t, fmt.Sprintf("value is %010d", some), "value is 0000000042")
+ assert.Equal(t, fmt.Sprintf("value is %d", none), "value is <none>")
+ assert.Equal(t, fmt.Sprintf("value is %d", some), "value is 42")
+ assert.Equal(t, fmt.Sprintf("value is %010d", none), "value is 0000<none>")
+ assert.Equal(t, fmt.Sprintf("value is %010d", some), "value is 0000000042")
noneList := None[[]int]()
someList := Some([]int{4, 2})
- AssertEqual(t, fmt.Sprintf("value is %v", noneList), "value is <none>")
- AssertEqual(t, fmt.Sprintf("value is %v", someList), "value is [4 2]")
- AssertEqual(t, fmt.Sprintf("value is %#v", noneList), "value is <none>")
- AssertEqual(t, fmt.Sprintf("value is %#v", someList), "value is []int{4, 2}")
+ assert.Equal(t, fmt.Sprintf("value is %v", noneList), "value is <none>")
+ assert.Equal(t, fmt.Sprintf("value is %v", someList), "value is [4 2]")
+ assert.Equal(t, fmt.Sprintf("value is %#v", noneList), "value is <none>")
+ assert.Equal(t, fmt.Sprintf("value is %#v", someList), "value is []int{4, 2}")
listOfOptions := []Option[int]{none, some}
- AssertEqual(t, fmt.Sprintf("value is %#v", listOfOptions), "value is []option.Option[int]{<none>, 42}")
+ assert.Equal(t, fmt.Sprintf("value is %#v", listOfOptions), "value is []option.Option[int]{<none>, 42}")
}
func TestMarshalSQL(t *testing.T) {
value, err := None[string]().Value()
- AssertEqual(t, err, nil)
- AssertEqual(t, value, nil)
+ assert.Equal(t, err, nil)
+ assert.Equal(t, value, nil)
value, err = Some("hello").Value()
- AssertEqual(t, err, nil)
- AssertEqual(t, value, "hello")
+ assert.Equal(t, err, nil)
+ assert.Equal(t, value, "hello")
_, err = Some(struct{}{}).Value()
- AssertEqual(t, err.Error(), "unsupported type struct {}, a struct")
+ assert.Equal(t, err.Error(), "unsupported type struct {}, a struct")
}
func TestUnmarshalSQL(t *testing.T) {
var o1 Option[string]
err := o1.Scan(nil)
- AssertEqual(t, err, nil)
- AssertEqual(t, o1, None[string]())
+ assert.Equal(t, err, nil)
+ assert.Equal(t, o1, None[string]())
var o2 Option[string]
err = o2.Scan("hello")
- AssertEqual(t, err, nil)
- AssertEqual(t, o2, Some("hello"))
+ assert.Equal(t, err, nil)
+ assert.Equal(t, o2, Some("hello"))
var o3 Option[struct{}]
err = o3.Scan("hello")
- AssertEqual(t, err.Error(), "unsupported Scan, storing driver.Value type string into type *struct {}")
+ assert.Equal(t, err.Error(), "unsupported Scan, storing driver.Value type string into type *struct {}")
}
func TestMarshalAndUnmarshalJSON(t *testing.T) {
@@ -208,11 +208,11 @@ func TestMarshalAndUnmarshalJSON(t *testing.T) {
S3: Some(3),
}
buf, err := json.Marshal(original)
- AssertEqual(t, err, nil)
- AssertEqual(t, string(buf), `{"n1":null,"n2":null,"s1":1,"s2":2,"s3":3}`)
+ assert.Equal(t, err, nil)
+ assert.Equal(t, string(buf), `{"n1":null,"n2":null,"s1":1,"s2":2,"s3":3}`)
var decoded payload
err = json.Unmarshal(buf, &decoded)
- AssertEqual(t, err, nil)
- AssertEqual(t, decoded, original)
+ assert.Equal(t, err, nil)
+ assert.Equal(t, decoded, original)
}
diff --git a/options/options_test.go b/options/options_test.go
index fab378b..89b9e14 100644
--- a/options/options_test.go
+++ b/options/options_test.go
@@ -7,62 +7,62 @@ import (
"strconv"
"testing"
- . "go.xyrillian.de/gg/internal/test"
+ "go.xyrillian.de/gg/assert"
. "go.xyrillian.de/gg/option"
)
func TestFromPointer(t *testing.T) {
- AssertEqual(t, FromPointer[int](nil), None[int]())
- AssertEqual(t, FromPointer(new(int(42))), Some(42))
+ assert.Equal(t, FromPointer[int](nil), None[int]())
+ assert.Equal(t, FromPointer(new(int(42))), Some(42))
}
func TestIsNoneOrZero(t *testing.T) {
- AssertEqual(t, IsNoneOrZero(None[int]()), true)
- AssertEqual(t, IsNoneOrZero(Some(0)), true)
- AssertEqual(t, IsNoneOrZero(Some(1)), false)
+ assert.Equal(t, IsNoneOrZero(None[int]()), true)
+ assert.Equal(t, IsNoneOrZero(Some(0)), true)
+ assert.Equal(t, IsNoneOrZero(Some(1)), false)
}
func TestMap(t *testing.T) {
- AssertEqual(t, Map(None[int](), strconv.Itoa), None[string]())
- AssertEqual(t, Map(Some(42), strconv.Itoa), Some("42"))
+ assert.Equal(t, Map(None[int](), strconv.Itoa), None[string]())
+ assert.Equal(t, Map(Some(42), strconv.Itoa), Some("42"))
}
func TestMax(t *testing.T) {
- AssertEqual(t, Max[int](), None[int]())
- AssertEqual(t, Max(None[int]()), None[int]())
- AssertEqual(t, Max(None[int](), None[int]()), None[int]())
+ assert.Equal(t, Max[int](), None[int]())
+ assert.Equal(t, Max(None[int]()), None[int]())
+ assert.Equal(t, Max(None[int](), None[int]()), None[int]())
- AssertEqual(t, Max(Some(5)), Some(5))
- AssertEqual(t, Max(Some(5), None[int]()), Some(5))
- AssertEqual(t, Max(None[int](), Some(5)), Some(5))
+ assert.Equal(t, Max(Some(5)), Some(5))
+ assert.Equal(t, Max(Some(5), None[int]()), Some(5))
+ assert.Equal(t, Max(None[int](), Some(5)), Some(5))
- AssertEqual(t, Max(Some(5), Some(23)), Some(23))
- AssertEqual(t, Max(None[int](), Some(5), Some(23)), Some(23))
- AssertEqual(t, Max(Some(5), None[int](), Some(23)), Some(23))
- AssertEqual(t, Max(Some(5), Some(23), None[int]()), Some(23))
+ assert.Equal(t, Max(Some(5), Some(23)), Some(23))
+ assert.Equal(t, Max(None[int](), Some(5), Some(23)), Some(23))
+ assert.Equal(t, Max(Some(5), None[int](), Some(23)), Some(23))
+ assert.Equal(t, Max(Some(5), Some(23), None[int]()), Some(23))
- AssertEqual(t, Max(Some(23), Some(5)), Some(23))
- AssertEqual(t, Max(None[int](), Some(23), Some(5)), Some(23))
- AssertEqual(t, Max(Some(23), None[int](), Some(5)), Some(23))
- AssertEqual(t, Max(Some(23), Some(5), None[int]()), Some(23))
+ assert.Equal(t, Max(Some(23), Some(5)), Some(23))
+ assert.Equal(t, Max(None[int](), Some(23), Some(5)), Some(23))
+ assert.Equal(t, Max(Some(23), None[int](), Some(5)), Some(23))
+ assert.Equal(t, Max(Some(23), Some(5), None[int]()), Some(23))
}
func TestMin(t *testing.T) {
- AssertEqual(t, Min[int](), None[int]())
- AssertEqual(t, Min(None[int]()), None[int]())
- AssertEqual(t, Min(None[int](), None[int]()), None[int]())
+ assert.Equal(t, Min[int](), None[int]())
+ assert.Equal(t, Min(None[int]()), None[int]())
+ assert.Equal(t, Min(None[int](), None[int]()), None[int]())
- AssertEqual(t, Min(Some(5)), Some(5))
- AssertEqual(t, Min(Some(5), None[int]()), Some(5))
- AssertEqual(t, Min(None[int](), Some(5)), Some(5))
+ assert.Equal(t, Min(Some(5)), Some(5))
+ assert.Equal(t, Min(Some(5), None[int]()), Some(5))
+ assert.Equal(t, Min(None[int](), Some(5)), Some(5))
- AssertEqual(t, Min(Some(5), Some(23)), Some(5))
- AssertEqual(t, Min(None[int](), Some(5), Some(23)), Some(5))
- AssertEqual(t, Min(Some(5), None[int](), Some(23)), Some(5))
- AssertEqual(t, Min(Some(5), Some(23), None[int]()), Some(5))
+ assert.Equal(t, Min(Some(5), Some(23)), Some(5))
+ assert.Equal(t, Min(None[int](), Some(5), Some(23)), Some(5))
+ assert.Equal(t, Min(Some(5), None[int](), Some(23)), Some(5))
+ assert.Equal(t, Min(Some(5), Some(23), None[int]()), Some(5))
- AssertEqual(t, Min(Some(23), Some(5)), Some(5))
- AssertEqual(t, Min(None[int](), Some(23), Some(5)), Some(5))
- AssertEqual(t, Min(Some(23), None[int](), Some(5)), Some(5))
- AssertEqual(t, Min(Some(23), Some(5), None[int]()), Some(5))
+ assert.Equal(t, Min(Some(23), Some(5)), Some(5))
+ assert.Equal(t, Min(None[int](), Some(23), Some(5)), Some(5))
+ assert.Equal(t, Min(Some(23), None[int](), Some(5)), Some(5))
+ assert.Equal(t, Min(Some(23), Some(5), None[int]()), Some(5))
}
diff --git a/testcapture/capture.go b/testcapture/capture.go
new file mode 100644
index 0000000..3ac4f52
--- /dev/null
+++ b/testcapture/capture.go
@@ -0,0 +1,416 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+// Package testcapture contains [Capture], a function that executes test code in a way that captures error messages and side effects without failing the overall test.
+//
+// The main intended use case is testing test assertions where calls to e.g. t.Error() are an expected part of a successful test run.
+package testcapture
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "runtime"
+ "slices"
+ "sync"
+ "sync/atomic"
+
+ "go.xyrillian.de/gg/assert"
+)
+
+// Result is returned by func [Capture].
+type Result struct {
+ // Outcome describes how the test ended.
+ Outcome Outcome
+ // Panic contains a payload recovered from a panic(), if Outcome is [OutcomePanicked].
+ Panic any
+ // Messages contains log lines captured from t.Log() calls, or functions calling t.Log(), such as t.Error() and t.Fatal();
+ // as well as data captured from t.Output().Write() calls.
+ Messages []Message
+ // Attrs contains attributes captured in t.Attr() calls.
+ Attrs map[string]string
+ // Artifacts holds the contents of any regular files that were created below t.ArtifactDir(), keyed with the path relative to t.ArtifactDir().
+ Artifacts map[string]string
+}
+
+// Outcome is an enum.
+// It appears in type [Result].
+type Outcome string
+
+const (
+ // OutcomeFinished describes a [Capture] that ended with the test running to completion.
+ OutcomeFinished Outcome = "finished"
+ // OutcomeFailed describes a [Capture] that ended early because of a t.FailNow() call.
+ OutcomeFailed Outcome = "failed"
+ // OutcomeSkipped describes a [Capture] that ended early because of a t.SkipNow() call.
+ OutcomeSkipped Outcome = "skipped"
+ // OutcomePanicked describes a [Capture] that ended early because of a panic() call.
+ OutcomePanicked Outcome = "panicked"
+)
+
+// Message is a piece of log output captured by func [Capture].
+// It appears in type [Result].
+// - Each call to t.Log(), t.Logf() or their derived functions results in one Message instance of type [Log].
+// - Writing into t.Output() between two calls to t.Log(), t.Logf() etc. results in a single Message instance of type [Output], even if Write() is called multiple times.
+type Message struct {
+ Message string
+ Type MessageType
+}
+
+// Log is a shorthand for constructing [Message] objects of type [MessageTypeLog].
+func Log[T interface{ ~string }](message T) Message {
+ return Message{string(message), MessageTypeLog}
+}
+
+// Output is a shorthand for constructing [Message] objects of type [MessageTypeOutput].
+func Output[T interface{ ~string | ~[]byte }](message T) Message {
+ return Message{string(message), MessageTypeOutput}
+}
+
+// MessageType is an enum.
+// It appears in type [Message].
+type MessageType string
+
+const (
+ // MessageTypeLog describes [Message] instances created by calls to t.Log(), t.Logf(), or functions calling them, such as t.Error() and t.Fatal().
+ MessageTypeLog MessageType = "log"
+ // MessageTypeOutput describes [Message] instances created by calls to t.Output().Write().
+ MessageTypeOutput MessageType = "output"
+)
+
+// Capture executes a test function with a stub implementation of [assert.TestingTB] that captures all calls to it.
+// It is intended for unit-testing test assertions.
+//
+// The name argument is what will be reported in t.Name() within the test.
+func Capture(ctx context.Context, name string, test func(assert.TestingTB)) Result {
+ r := Result{
+ Outcome: OutcomeFinished, // can be overridden by Fail() or SkipNow()
+ }
+ executeCapture(ctx, name, &r, test)
+ return r
+}
+
+// capturer is the implementation of [assert.TestingTB] used by func [Capture].
+type capturer struct {
+ context context.Context
+ cleanups []func()
+ name string
+ result *Result
+ state struct {
+ ArtifactDir string
+ }
+
+ cleanupsMutex sync.Mutex // lock for access to the `cleanups` field
+ resultMutex sync.RWMutex // lock for access to the `result` field
+ stateMutex sync.Mutex // lock for access to the `state` field
+ nonlocalMutex sync.Mutex // lock for non-local effects like Setenv() or filesystem operations
+}
+
+func executeCapture(ctx context.Context, name string, r *Result, test func(assert.TestingTB)) {
+ ctx, cancel := context.WithCancel(ctx)
+ t := capturer{
+ context: ctx,
+ cleanups: nil,
+ name: name,
+ result: r,
+ }
+ defer func() {
+ t.setOutcome(recover())
+ cancel() // T.Context() demands that the context be canceled before any cleanup handlers
+ for _, cleanup := range slices.Backward(t.cleanups) {
+ cleanup()
+ }
+ }()
+ test(&t)
+}
+
+func (t *capturer) setOutcome(panicPayload any) {
+ t.resultMutex.Lock()
+ defer t.resultMutex.Unlock()
+ if panicPayload == nil {
+ return
+ } else if outcome, ok := panicPayload.(Outcome); ok {
+ t.result.Outcome = outcome
+ } else {
+ t.result.Outcome = OutcomePanicked
+ t.result.Panic = panicPayload
+ }
+}
+
+func (t *capturer) pushOutput(buf []byte, msgType MessageType) {
+ t.resultMutex.Lock()
+ defer t.resultMutex.Unlock()
+
+ // try to merge consecutive t.Output().Write() calls together
+ if msgType == MessageTypeOutput && len(t.result.Messages) > 0 {
+ idx := len(t.result.Messages) - 1
+ msg := t.result.Messages[idx]
+ if msg.Type == MessageTypeOutput {
+ msg.Message = msg.Message + string(buf)
+ t.result.Messages[idx] = msg
+ return
+ }
+ }
+
+ t.result.Messages = append(t.result.Messages, Message{
+ Message: string(buf),
+ Type: msgType,
+ })
+}
+
+var tempdirID atomic.Uint64
+
+func pickTempdir() (string, error) {
+ path := filepath.Join(os.TempDir(), fmt.Sprintf("gg-assert-capture-%d", tempdirID.Add(1)))
+ return path, os.MkdirAll(path, 0777)
+}
+
+func collectArtifacts(dirPath string) (map[string]string, error) {
+ dir, err := os.OpenRoot(dirPath)
+ if err != nil {
+ return nil, err
+ }
+ dirFS := dir.FS()
+
+ result := make(map[string]string)
+ err = fs.WalkDir(dirFS, ".", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.Type().IsRegular() {
+ buf, err := fs.ReadFile(dirFS, path)
+ if err != nil {
+ return err
+ }
+ result[path] = string(buf)
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return result, os.RemoveAll(dirPath)
+}
+
+// ArtifactDir implements the [assert.TestingTB] interface.
+func (t *capturer) ArtifactDir() string {
+ t.stateMutex.Lock()
+ defer t.stateMutex.Unlock()
+ if t.state.ArtifactDir == "" {
+ path, err := pickTempdir()
+ if err != nil {
+ t.Fatal("in t.ArtifactDir(): ", err)
+ }
+ t.state.ArtifactDir = path
+
+ t.Cleanup(func() {
+ artifacts, err := collectArtifacts(path)
+ if err == nil {
+ t.resultMutex.Lock()
+ defer t.resultMutex.Unlock()
+ t.result.Artifacts = artifacts
+ } else {
+ t.Error(err)
+ }
+ })
+ }
+ return t.state.ArtifactDir
+}
+
+// Attr implements the [assert.TestingTB] interface.
+func (t *capturer) Attr(key, value string) {
+ t.resultMutex.Lock()
+ defer t.resultMutex.Unlock()
+ if t.result.Attrs == nil {
+ t.result.Attrs = make(map[string]string)
+ }
+ t.result.Attrs[key] = value
+}
+
+// Chdir implements the [assert.TestingTB] interface.
+func (t *capturer) Chdir(dir string) {
+ t.doChdir(dir)
+
+ // the following is done outside of doChdir() because t.Setenv() also locks t.nonlocalMutex
+ switch runtime.GOOS {
+ case "windows", "plan9":
+ // these platforms do not use the PWD variable
+ default:
+ dir, err := os.Getwd() // returns an absolute path even if `dir` is not one
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PWD", dir)
+ }
+}
+
+func (t *capturer) doChdir(dir string) {
+ t.nonlocalMutex.Lock()
+ defer t.nonlocalMutex.Unlock()
+
+ oldDir, err := os.Open(".")
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = os.Chdir(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ t.Cleanup(func() {
+ err := oldDir.Chdir()
+ if err != nil {
+ t.Error("could not reset cwd changed by t.Chdir(): ", err)
+ }
+ })
+}
+
+// Cleanup implements the [assert.TestingTB] interface.
+func (t *capturer) Cleanup(action func()) {
+ t.cleanupsMutex.Lock()
+ defer t.cleanupsMutex.Unlock()
+ t.cleanups = append(t.cleanups, action)
+}
+
+// Context implements the [assert.TestingTB] interface.
+func (t *capturer) Context() context.Context {
+ return t.context
+}
+
+// Error implements the [assert.TestingTB] interface.
+func (t *capturer) Error(args ...any) {
+ t.Log(args...)
+ t.Fail()
+}
+
+// Errorf implements the [assert.TestingTB] interface.
+func (t *capturer) Errorf(format string, args ...any) {
+ t.Logf(format, args...)
+ t.Fail()
+}
+
+// Fail implements the [assert.TestingTB] interface.
+func (t *capturer) Fail() {
+ t.resultMutex.Lock()
+ defer t.resultMutex.Unlock()
+ t.result.Outcome = OutcomeFailed
+}
+
+// Failed implements the [assert.TestingTB] interface.
+func (t *capturer) Failed() bool {
+ t.resultMutex.RLock()
+ defer t.resultMutex.RUnlock()
+ return t.result.Outcome == OutcomeFailed
+}
+
+// FailNow implements the [assert.TestingTB] interface.
+func (t *capturer) FailNow() {
+ panic(OutcomeFailed)
+}
+
+// Fatal implements the [assert.TestingTB] interface.
+func (t *capturer) Fatal(args ...any) {
+ t.Log(args...)
+ t.FailNow()
+}
+
+// Fatalf implements the [assert.TestingTB] interface.
+func (t *capturer) Fatalf(format string, args ...any) {
+ t.Logf(format, args...)
+ t.FailNow()
+}
+
+// Helper implements the [assert.TestingTB] interface.
+func (t *capturer) Helper() {
+ // no-op because we do not collect file and line information at the moment
+}
+
+// Log implements the [assert.TestingTB] interface.
+func (t *capturer) Log(args ...any) {
+ t.pushOutput(fmt.Append(nil, args...), MessageTypeLog)
+}
+
+// Logf implements the [assert.TestingTB] interface.
+func (t *capturer) Logf(format string, args ...any) {
+ t.pushOutput(fmt.Appendf(nil, format, args...), MessageTypeLog)
+}
+
+// Name implements the [assert.TestingTB] interface.
+func (t *capturer) Name() string {
+ return t.name
+}
+
+// Output implements the [assert.TestingTB] interface.
+func (t *capturer) Output() io.Writer {
+ return outputCapturer{t}
+}
+
+type outputCapturer struct {
+ t *capturer
+}
+
+// Write implements the [io.Writer] interface.
+func (c outputCapturer) Write(buf []byte) (int, error) {
+ c.t.pushOutput(buf, MessageTypeOutput)
+ return len(buf), nil
+}
+
+// Setenv implements the [assert.TestingTB] interface.
+func (t *capturer) Setenv(key, value string) {
+ t.nonlocalMutex.Lock()
+ defer t.nonlocalMutex.Unlock()
+
+ oldValue, hasOldValue := os.LookupEnv(key)
+ os.Setenv(key, value)
+
+ t.Cleanup(func() {
+ if hasOldValue {
+ os.Setenv(key, oldValue)
+ } else {
+ os.Unsetenv(key)
+ }
+ })
+}
+
+// Skip implements the [assert.TestingTB] interface.
+func (t *capturer) Skip(args ...any) {
+ t.Log(args...)
+ t.SkipNow()
+}
+
+// Skipf implements the [assert.TestingTB] interface.
+func (t *capturer) Skipf(format string, args ...any) {
+ t.Logf(format, args...)
+ t.SkipNow()
+}
+
+// SkipNow implements the [assert.TestingTB] interface.
+func (t *capturer) SkipNow() {
+ panic(OutcomeSkipped)
+}
+
+// Skipped implements the [assert.TestingTB] interface.
+func (t *capturer) Skipped() bool {
+ t.resultMutex.RLock()
+ defer t.resultMutex.RUnlock()
+ return t.result.Outcome == OutcomeSkipped
+}
+
+// TempDir implements the [assert.TestingTB] interface.
+func (t *capturer) TempDir() string {
+ path, err := pickTempdir()
+ if err != nil {
+ t.Fatal("in t.TempDir(): ", err)
+ }
+ t.Cleanup(func() {
+ err := os.RemoveAll(path)
+ if err != nil {
+ t.Error(err)
+ }
+ })
+ return path
+}
diff --git a/testcapture/capture_test.go b/testcapture/capture_test.go
new file mode 100644
index 0000000..9e53b71
--- /dev/null
+++ b/testcapture/capture_test.go
@@ -0,0 +1,403 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package testcapture_test
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "go.xyrillian.de/gg/assert"
+ "go.xyrillian.de/gg/testcapture"
+)
+
+func TestCaptureArtifactDir(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ // test that writing a regular file works
+ fooPath := filepath.Join(t.ArtifactDir(), "foo.txt")
+ err := os.WriteFile(fooPath, []byte("Hello World."), 0666)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // test that nesting regular files into directories works
+ // (and also, implicitly, that multiple calls to ArtifactDir() return the same path)
+ barPath := filepath.Join(t.ArtifactDir(), "bar/a/b/c/d/data.json")
+ err = os.MkdirAll(filepath.Dir(barPath), 0777)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = os.WriteFile(barPath, []byte(`{"bar":42}`), 0666)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // test that empty directories are ignored by the capture
+ err = os.MkdirAll(filepath.Join(t.ArtifactDir(), "unused"), 0777)
+ if err != nil {
+ t.Fatal(err)
+ }
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeFinished,
+ Artifacts: map[string]string{
+ "foo.txt": "Hello World.",
+ "bar/a/b/c/d/data.json": `{"bar":42}`,
+ },
+ })
+}
+
+func TestCaptureAttr(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ t.Attr("foo", "bar")
+ t.Attr("foo", "baz")
+ t.Attr("hello", "world")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeFinished,
+ Attrs: map[string]string{
+ "foo": "baz",
+ "hello": "world",
+ },
+ })
+}
+
+func TestCaptureChdir(t *testing.T) {
+ var cwdInCapture string
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ // ArtifactDir() is used as a target for Chdir()...
+ dir := t.ArtifactDir()
+ t.Chdir(dir)
+ // ...because we have an easy way to check if we are actually in that dir
+ err := os.WriteFile("foo.txt", []byte("foo"), 0666)
+ if err != nil {
+ t.Error(err)
+ }
+
+ // smuggle the cwd out of the capture for the cleanup test below
+ cwdInCapture, err = os.Getwd()
+ if err != nil {
+ t.Error(err)
+ }
+
+ // t.Chdir() should have set $PWD to an absolute path
+ assert.Equal(t, os.Getenv("PWD"), cwdInCapture)
+ assert.Equal(t, filepath.IsAbs(cwdInCapture), true)
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeFinished,
+ Artifacts: map[string]string{"foo.txt": "foo"},
+ })
+
+ // check that we reset the working directory at the end of the test
+ cwdAfterCapture, err := os.Getwd()
+ if err != nil {
+ t.Error(err)
+ }
+ if cwdAfterCapture == cwdInCapture {
+ t.Error("cwd should have been reset, but still is", cwdInCapture)
+ }
+}
+
+func TestCaptureCleanup(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ // test that cleanups run in reverse order of registration, and only after the test itself is done
+ t.Log("starting up")
+ t.Cleanup(func() { t.Log("first cleanup") })
+ t.Cleanup(func() { t.Log("second cleanup") })
+ t.Cleanup(func() { t.Log("third cleanup") })
+ t.Log("shutting down")
+
+ // test that cleanups run even after a panic
+ panic("kaboom")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomePanicked,
+ Messages: []testcapture.Message{
+ testcapture.Log("starting up"),
+ testcapture.Log("shutting down"),
+ testcapture.Log("third cleanup"),
+ testcapture.Log("second cleanup"),
+ testcapture.Log("first cleanup"),
+ },
+ Panic: "kaboom",
+ })
+}
+
+func TestCaptureContext(t *testing.T) {
+ ctx := context.WithValue(t.Context(), "foo", "bar") //nolint:staticcheck // we do not care about type collision risks for this simple test
+ result := testcapture.Capture(ctx, t.Name(), func(t assert.TestingTB) {
+ // test that the context is live within the test
+ err := t.Context().Err()
+ if err != nil {
+ t.Error(err)
+ }
+
+ // test that the context is expired at cleanup time
+ t.Cleanup(func() {
+ err := t.Context().Err()
+ if err == nil {
+ t.Error("still alive!?")
+ }
+ })
+
+ // test that the context is derived from the one passed to Capture()
+ value := ctx.Value("foo")
+ if value != "bar" {
+ t.Error("did not see the value")
+ }
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeFinished,
+ })
+}
+
+func TestCaptureError(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ simpleError := errors.New("bar")
+ t.Error("foo: ", simpleError)
+ // check that the test keeps going, and that t.Log() does not reset the Outcome
+ t.Log("still going")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeFailed,
+ Messages: []testcapture.Message{
+ testcapture.Log("foo: bar"),
+ testcapture.Log("still going"),
+ },
+ })
+}
+
+func TestCaptureErrorf(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ t.Errorf("foo = %d", 42)
+ // check that the test keeps going, and that t.Log() does not reset the Outcome
+ t.Log("still going")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeFailed,
+ Messages: []testcapture.Message{
+ testcapture.Log("foo = 42"),
+ testcapture.Log("still going"),
+ },
+ })
+}
+
+func TestCaptureFail(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ if !t.Failed() {
+ t.Log("looking good so far")
+ }
+ t.Fail()
+ if t.Failed() {
+ t.Log("still going")
+ }
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeFailed,
+ Messages: []testcapture.Message{
+ testcapture.Log("looking good so far"),
+ testcapture.Log("still going"),
+ },
+ })
+}
+
+func TestCaptureFailNow(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ if !t.Failed() {
+ t.Log("looking good so far")
+ }
+ t.FailNow()
+ t.Log("still going")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeFailed,
+ Messages: []testcapture.Message{
+ testcapture.Log("looking good so far"),
+ // "still going" is not logged because FailNow() bails
+ },
+ })
+}
+
+func TestCaptureFatal(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ t.Log("looking good so far")
+ t.Fatal("kaboom")
+ t.Log("still going")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeFailed,
+ Messages: []testcapture.Message{
+ testcapture.Log("looking good so far"),
+ testcapture.Log("kaboom"),
+ // "still going" is not logged because Fatal() bails
+ },
+ })
+}
+
+func TestCaptureFatalf(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ t.Log("looking good so far")
+ t.Fatalf("kaboom %d", 42)
+ t.Log("still going")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeFailed,
+ Messages: []testcapture.Message{
+ testcapture.Log("looking good so far"),
+ testcapture.Log("kaboom 42"),
+ // "still going" is not logged because Fatalf() bails
+ },
+ })
+}
+
+func TestCaptureName(t *testing.T) {
+ result := testcapture.Capture(t.Context(), "Harold", func(t assert.TestingTB) {
+ panic(t.Name() + " died")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomePanicked,
+ Panic: "Harold died",
+ })
+}
+
+func TestCaptureOutput(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ t.Log("hello 1")
+ for range 10 {
+ fmt.Fprintln(t.Output(), "a")
+ }
+ t.Log("hello 2")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeFinished,
+ Messages: []testcapture.Message{
+ testcapture.Log("hello 1"),
+ testcapture.Output(strings.Repeat("a\n", 10)),
+ testcapture.Log("hello 2"),
+ },
+ })
+}
+
+func TestCapturePanic(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ t.Error("we are going to blow up")
+ panic("kaboom")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomePanicked, // Panicked takes precedence over Failed
+ Messages: []testcapture.Message{
+ testcapture.Log("we are going to blow up"),
+ },
+ Panic: "kaboom",
+ })
+}
+
+func TestCaptureSetenv(t *testing.T) {
+ t.Setenv("GG_TEST_SETENV_SCOPE", "outer")
+
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ // test t.Setenv() overriding an existing variable
+ assert.Equal(t, os.Getenv("GG_TEST_SETENV_SCOPE"), "outer")
+ t.Setenv("GG_TEST_SETENV_SCOPE", "inner")
+ assert.Equal(t, os.Getenv("GG_TEST_SETENV_SCOPE"), "inner")
+
+ // test t.Setenv() setting a fresh variable
+ // (this variable should be completely removed after the end of the test)
+ t.Setenv("GG_TEST_SETENV_PAYLOAD", "42")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeFinished,
+ })
+
+ assert.Equal(t, os.Getenv("GG_TEST_SETENV_SCOPE"), "outer")
+ _, ok := os.LookupEnv("GG_TEST_SETENV_PAYLOAD")
+ assert.Equal(t, ok, false)
+}
+
+func TestCaptureSkip(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ if !t.Skipped() {
+ t.Skip("this looks uninteresting")
+ }
+ t.Log("still going")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeSkipped,
+ Messages: []testcapture.Message{
+ testcapture.Log("this looks uninteresting"),
+ // "still going" is not logged because Skip() bails
+ },
+ })
+}
+
+func TestCaptureSkipf(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ if !t.Skipped() {
+ t.Skipf("pretty sure the answer is %d", 42)
+ }
+ t.Log("still going")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeSkipped,
+ Messages: []testcapture.Message{
+ testcapture.Log("pretty sure the answer is 42"),
+ // "still going" is not logged because Skipf() bails
+ },
+ })
+}
+
+func TestCaptureSkipNow(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ if !t.Skipped() {
+ t.Log("this looks uninteresting")
+ }
+ t.SkipNow()
+ t.Log("still going")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeSkipped,
+ Messages: []testcapture.Message{
+ testcapture.Log("this looks uninteresting"),
+ // "still going" is not logged because Skip() bails
+ },
+ })
+}
+
+func TestCaptureTempDir(t *testing.T) {
+ var path string
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ // fill a TempDir with some stuff
+ path = t.TempDir()
+ err := os.MkdirAll(filepath.Join(path, "emptydir"), 0777)
+ if err != nil {
+ t.Error(err)
+ }
+ err = os.WriteFile(filepath.Join(path, "data.json"), []byte(`{"username":"admin"}`), 0666)
+ if err != nil {
+ t.Error(err)
+ }
+
+ // check that each call to TempDir returns a new dir
+ otherPath := t.TempDir()
+ if otherPath == path {
+ t.Error("should have returned a different TempDir")
+ }
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeFinished,
+ })
+
+ // check that TempDir was cleaned up
+ _, err := os.Stat(path)
+ if err == nil {
+ t.Error("TempDir was not cleaned up")
+ } else if !os.IsNotExist(err) {
+ t.Error(err)
+ }
+}