README ¶
correct
Mission: to cleanly express the inputs and outputs that prove a piece of software as correct.
Correct is a collection of assertion libraries for go, intended to be used
together. We try not to strictly enforce that, though - most of correct
should
be customizeable either by changing some options or by using only some
sub-packages of correct
.
Quick start
$ go get git.sr.ht/~nelsam/correct@latest
Simple assertions are pretty obvious:
func TestFoo(t *testing.T) {
val := somepkg.SomeFn()
it.Must(t, val, match.Equal(anExpectedValue))
}
Motivation
An assertion library has three responsibilities:
- Compare business values using logic that is equivalent to business logic.
- if a value would fail a business logic comparison, it must also fail the assertion.
- Provide context about why a value failed a check.
- Output must be clear about which value came from business logic, which value was expected, and why the check failed.
- On failure, mark the test as failed and provide a way to stop test execution.
- If I assert that
foo
is a slice of length3
, then performingfoo[2]
on the next line must not panic the test.
- If I assert that
Existing libraries perform all of these with various levels of success, but I have never found a library that performs them all well. And over time, I've come to the conclusion that the unix philosophy is a good way of building a package - each package should "do one thing and do it well". Assertion libraries usually conflate some or all of these concerns.
So I set forth with the following goals:
- Separate these concerns into separate sub-packages.
- One package to format test results.
- One package to compare values.
- One package to handle the test result, printing the result's description and handling the result's passed/failed status.
- Make the simplest checks clear, clean, and obvious.
- Make the complex checks possible without implementing several methods on a matcher type.
- Never leave it ambiguous which value was the actual and which was the expected.
How we implement these goals
One package to format test results
result
is a package of result types which can describe match results. Result
types will describe the match using a theme, which may be set during init()
with result.InitDefaultTheme
or overridden in the context.Context
.
One package to compare values
match
is a sub-package with match functions. These are simply functions which
take an actual value and return a result. Many of them are constructed using an
expected value (or set of values).
A matcher in correct
is any function which takes a single parameter and
returns an it.Result
, so implementing custom matchers is as easy as:
func HasUserID(id string) func(*User) it.Result {
return func(u *User) it.Result {
if u.ID != id {
return result.Compare(false, u, "does not have ID", id)
}
return result.Compare(true, u, "has ID", id)
}
}
One package to wrap everything up and mark the test as failed
it
contains functions which take actuals and match functions, track results,
and call methods on the *testing.T
(usually Fatalf
). Extra options, like
telling the assertion to print on success as well as failure, exist as
functional options.
Generally, you'll use something like it.Must(t, foo, match.Equal("bar"))
.
Never leave it ambiguous which value was the actual and which was the expected
Functions in it
always take an actual and a match function. The match function
may be constructed around expected values, but it should never be ambiguous that
those values are constructing the match function.
Simplest checks are clear, clean, and obvious
None of these simple assertions should look complicated or confusing:
func TestFoo(t *testing.T) {
var s []string
err := json.Unmarshal(someJSONArray, &s)
it.Must(t, err, match.NotErr)
it.Must(t, s, match.HaveSliceLen[string](10))
it.Must(t, s[9], match.Equal("foo"))
}
Complex checks are possible without complex custom matchers
Match function
In correct
, matchers are just functions. This means that a custom matcher can
be defined in a closure - no need to share outside of your test function. You
don't even need to assign the matcher to a variable if you don't want to - just
define the closure in the call directly.
func TestFoo(t *testing.T) {
someVal := someFn()
it.Must(t, someVal, func(v foo.SomeType) it.Result {
if v.Status == statusFailed {
return result.Simple(false, v, "has a failed status")
}
return result.Simple(true, v, "does not have a failed status")
})
}
Use Map to extract data from complex types
Map
, inspired by functional programming map
functions like elm's
Maybe.map,
provides a powerful method of performing a match against a complex value. For
example, here is a test that wraps up multiple Map
assertions in All
to
check the response code, Authorization header, and JSON body of an HTTP
response:
func TestHTTP(t *testing.T) {
code := match.Map(func(r *http.Response) int {
return r.StatusCode
})
authHeader := match.Map(
func(r *http.Response) string {
return resp.Header.Get("Authorization")
},
match.DescribeMap("response auth header"),
)
jsonBody := match.Map(
func(r *http.Response) map[string]any {
m := make(map[string]any)
err := json.NewDecoder(resp.Body).Decode(&m)
it.Must(t, err, match.NotErr)
return m
},
match.DescribeMap("response JSON body")
)
resp, err := somepkg.DoRequest()
it.Must(t, err, match.NotErr)
it.Must(t, resp, match.All(
code(match.Equal(http.StatusOK)),
authHeader(match.Equal("some-auth-header")),
jsonBody(match.DeepEqual(map[string]any{"jsonKey": "jsonVal"})),
))
}
The only custom code above is the set of functions that we pass to Map
- which
are easy enough to define as variables in our test function.