Skip to content

Commit

Permalink
refactor: refactor fluent API & source printing for better readability (
Browse files Browse the repository at this point in the history
#22)

This change refactors the fluent API of the package to make the test
source code flow better in terms of readability. For example, instead
of:

```go
With(t).Verify(1).Will(EqualTo(2)).OrFail()
```

The API is now either:

```go
With(t).EnsureThat("1 equals 2").ByVerifying(1).Will(EqualTo(2)).Now()
With(t).VerifyThat(1).Will(EqualTo(2)).Now()
```

The call to "EnsureThat" is optional and is meant to help documenting
the test in case of failures. The change from ".OrFail()" to ".Now()"
ensures that every assertion ends with a timing specification:

```go
With(t).VerifyThat(1).Will(EqualTo(2)).Now()
With(t).VerifyThat(1).Will(EqualTo(2)).For(...)
With(t).VerifyThat(1).Will(EqualTo(2))Within(...)
```

Additionally, this refactor improves the printing of the assertion's
source code location, enabling support for multi-line location source
code. For instance, for the following assertion:

```go
With(t).
    EnsureThat("...").
    ByVerifying(1).
    Will(EqualTo(2)).
    Now()
```

Previous versions only printed the line containing the "Now()" call, but
this version will print the entire statement (from the "With(t)" up to
and including the call to "Now()".)
  • Loading branch information
arikkfir authored Jun 23, 2024
1 parent 2458d4e commit 0936ebf
Show file tree
Hide file tree
Showing 17 changed files with 197 additions and 89 deletions.
42 changes: 21 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,48 +43,48 @@ import (
func TestSomething(t *testing.T) {

// Simple assertions
With(t).Verify(1).Will(BeBetween(0, 2)).OrFail()
With(t).Verify("").Will(BeEmpty()).OrFail()
With(t).Verify([]int{1,2,3}).Will(BeEmpty()).OrFail() // <-- This will fail!
With(t).Verify(1).Will(BeGreaterThan(0)).OrFail()
With(t).Verify(1).Will(BeLessThan(2)).OrFail()
With(t).Verify("abc").Will(BeNil()).OrFail() // <-- This will fail!
With(t).Verify(1).Will(EqualTo(1)).OrFail()
With(t).Verify("abc").Will(EqualTo("def")).OrFail() // <-- This will fail!
With(t).VerifyThat(1).Will(BeBetween(0, 2)).Now()
With(t).VerifyThat("").Will(BeEmpty()).Now()
With(t).VerifyThat([]int{1, 2, 3}).Will(BeEmpty()).Now() // <-- This will fail!
With(t).VerifyThat(1).Will(BeGreaterThan(0)).Now()
With(t).VerifyThat(1).Will(BeLessThan(2)).Now()
With(t).VerifyThat("abc").Will(BeNil()).Now() // <-- This will fail!
With(t).VerifyThat(1).Will(EqualTo(1)).Now()
With(t).VerifyThat("abc").Will(EqualTo("def")).Now() // <-- This will fail!

// Assert success or failure of a function (functions can have any set of return values or none at all)
succeedingFunc := func() (string, error) { return "abc", nil }
With(t).Verify(succeedingFunc).Will(Succeed()).OrFail() // <-- Will succeed since error return value is nil
With(t).Verify(succeedingFunc).Will(Fail()).OrFail() // <-- Will fail since it expects error return value to be non-nil
With(t).VerifyThat(succeedingFunc).Will(Succeed()).Now() // <-- Will succeed since error return value is nil
With(t).VerifyThat(succeedingFunc).Will(Fail()).Now() // <-- Will fail since it expects error return value to be non-nil
failingFunc := func() (string, error) { return "", fmt.Errorf("error") }
With(t).Verify(failingFunc).Will(Succeed()).OrFail() // <-- Will fail since error return value is not nil
With(t).Verify(failingFunc).Will(Fail()).OrFail() // <-- Will succeed since it expects error return value to be non-nil
With(t).VerifyThat(failingFunc).Will(Succeed()).Now() // <-- Will fail since error return value is not nil
With(t).VerifyThat(failingFunc).Will(Fail()).Now() // <-- Will succeed since it expects error return value to be non-nil

// Assert negation of another assertion
With(t).Verify(1).Will(Not(EqualTo(2))).OrFail()
With(t).VerifyThat(1).Will(Not(EqualTo(2))).Now()

// Assert something will **eventually** match
// It will stop when the function succeeds (no assertion failure) or when time runs out
With(t).Verify(func(t T) {
With(t).VerifyThat(func(t T) {

// Will be invoked every 100ms until either it no longer fails or until time runs out (10s)
With(t).Verify(2).Will(EqualTo(2)).OrFail()
With(t).VerifyThat(2).Will(EqualTo(2)).Now()

}).Will(Succeed()).Within(10*time.Second, 100*time.Millisecond)

// Assert something will **repeatedly** match for a certain amount of time
// It will stop on the first time the function fails
With(t).Verify(func(t T) {
With(t).VerifyThat(func(t T) {

// Will be invoked every 100ms until either it fails or until time runs out (10s)
With(t).Verify(2).Will(EqualTo(2)).OrFail()
With(t).VerifyThat(2).Will(EqualTo(2)).Now()

}).Will(Succeed()).For(10*time.Second, 100*time.Millisecond)

// Assert on text patterns
With(t).Verify("abc").Will(Say("^a*c$")).OrFail()
With(t).Verify("abc").Will(Say(regexp.MustCompile("^a*c$"))).OrFail()
With(t).Verify([]byte("abc")).Will(Say("^a*c$")).OrFail()
With(t).VerifyThat("abc").Will(Say("^a*c$")).Now()
With(t).VerifyThat("abc").Will(Say(regexp.MustCompile("^a*c$"))).Now()
With(t).VerifyThat([]byte("abc")).Will(Say("^a*c$")).Now()
}
```

Expand All @@ -105,7 +105,7 @@ var (
myValueExtractor = NewValueExtractor(ExtractSameValue)
)

// BeSuperDuper returns a matcher that will ensure that each actual value passed to "With(t).Verify(...)" will be either
// BeSuperDuper returns a matcher that will ensure that each actual value passed to "With(t).VerifyThat(...)" will be either
// "super duper" or "extra super duper", depending on the value of the `extraDuper` parameter.
func BeSuperDuper(extraDuper bool) Matcher {
return MatcherFunc(func(t T, actuals ...any) {
Expand Down
70 changes: 49 additions & 21 deletions asserter.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"fmt"
"path/filepath"
"regexp"
"strings"
"time"

"github.com/arikkfir/justest/internal"
Expand All @@ -13,35 +12,63 @@ import (
const SlowFactorEnvVarName = "JUSTEST_SLOW_FACTOR"

//go:noinline
func With(t T) VerifierAndEnsurer {
func With(t T) VerifyOrEnsure {
if t == nil {
panic("given T instance must not be nil")
}
GetHelper(t).Helper()
return &verifier{t: t}
}

type VerifierAndEnsurer interface {
Verifier
Ensure(string, ...any) Verifier
}
type VerifyOrEnsure interface {
// EnsureThat adds a description to the upcoming assertion, which will be printed in case it fails.
EnsureThat(string, ...any) Ensurer

// Deprecated: Ensure is a synonym for EnsureThat.
Ensure(string, ...any) Ensurer

type Verifier interface {
// VerifyThat starts an assertion without a description.
VerifyThat(actuals ...any) Asserter

// Deprecated: Verify is a synonym for VerifyThat.
Verify(actuals ...any) Asserter
}

type Ensurer interface {
ByVerifying(actuals ...any) Asserter
}

type verifier struct {
t T
desc string
}

//go:noinline
func (v *verifier) Ensure(format string, args ...any) Verifier {
func (v *verifier) EnsureThat(format string, args ...any) Ensurer {
GetHelper(v.t).Helper()
v.desc = fmt.Sprintf(format, args...)
return v
}

//go:noinline
func (v *verifier) Ensure(format string, args ...any) Ensurer {
GetHelper(v.t).Helper()
v.desc = fmt.Sprintf(format, args...)
return v
}

//go:noinline
func (v *verifier) ByVerifying(actuals ...any) Asserter {
GetHelper(v.t).Helper()
return &asserter{t: v.t, desc: v.desc, actuals: actuals}
}

//go:noinline
func (v *verifier) VerifyThat(actuals ...any) Asserter {
GetHelper(v.t).Helper()
return &asserter{t: v.t, desc: v.desc, actuals: actuals}
}

//go:noinline
func (v *verifier) Verify(actuals ...any) Asserter {
GetHelper(v.t).Helper()
Expand Down Expand Up @@ -81,7 +108,7 @@ func (a *asserter) Will(m Matcher) Assertion {
}

type Assertion interface {
OrFail()
Now()
For(duration time.Duration, interval time.Duration)
Within(duration time.Duration, interval time.Duration)
}
Expand All @@ -98,7 +125,7 @@ type assertion struct {
}

//go:noinline
func (a *assertion) OrFail() {
func (a *assertion) Now() {
GetHelper(a.t).Helper()
if a.evaluated {
panic("assertion already evaluated")
Expand Down Expand Up @@ -309,8 +336,7 @@ func (a *assertion) Fatalf(format string, args ...any) {
GetHelper(a).Helper()

if a.desc != "" {
f := strings.ToLower(string(format[0])) + format[1:]
format = a.desc + " failed: " + f
format = fmt.Sprintf("Assertion that %s failed: %s", a.desc, format)
}

if a.contain {
Expand All @@ -319,18 +345,20 @@ func (a *assertion) Fatalf(format string, args ...any) {
caller := internal.CallerAt(1)
callerFunction, callerFile, callerLine := caller.Location()

format = format + "\n%s:%d --> %s"
if matches, err := regexp.MatchString(`.*/arikkfir/justest\.`, callerFunction); err != nil {
// Check if direct caller is from within the "justest" package; if NOT (application test code) print the caller
if internalCall, err := regexp.MatchString(`.*/arikkfir/justest\.`, callerFunction); err != nil {
panic(fmt.Errorf("illegal regexp matching: %+v", err))
} else if matches {
// Caller is "justest" internal (e.g. "a.OrFail", "a.For", "a.Within") - only add the assertion location
args = append(args, filepath.Base(a.location.File), a.location.Line, a.location.Source)
} else {
// Caller is not "justest" internal - add both the assertion and the caller locations
} else if !internalCall {
// Direct caller is NOT from the "justest" package; thus we also print the caller, in addition to the
// location of the actual assertion (which is always printed)
format = format + "\n%s:%d --> %s"
args = append(args, filepath.Base(callerFile), callerLine, readSourceAt(callerFile, callerLine))
args = append(args, filepath.Base(a.location.File), a.location.Line, a.location.Source)
args = append(args, filepath.Base(callerFile), callerLine, indentIfMultiLine(readSourceAt(callerFile, callerLine)))
}

// Always print the assertion location
format = format + "\n%s:%d --> %s"
args = append(args, filepath.Base(a.location.File), a.location.Line, indentIfMultiLine(a.location.Source))

a.t.Fatalf(format, args...)
}
}
Expand Down
39 changes: 29 additions & 10 deletions asserter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,27 @@ func TestWith(t *testing.T) {
t.Run("description propagated to failure message", func(t *testing.T) {
t.Parallel()
mt := NewMockT(t)
defer mt.Verify(FailureVerifier("^user feature failed: unexpected.*"))
With(mt).Ensure("user feature").Verify(1).Will(EqualTo(2)).OrFail()
defer mt.Verify(FailureVerifier("^Assertion that 1 equals 2 failed: Unexpected.*"))
With(mt).EnsureThat("1 equals 2").ByVerifying(1).Will(EqualTo(2)).Now()
})
}

func TestDescription(t *testing.T) {
t.Parallel()
t.Run("single line description", func(t *testing.T) {
t.Parallel()
mt := NewMockT(t)
defer mt.Verify(FailureVerifier("^Assertion that one equals two failed: Unexpected.*"))
With(mt).EnsureThat("one equals two").ByVerifying(1).Will(EqualTo(2)).Now()
})
t.Run("multi line description", func(t *testing.T) {
t.Parallel()
mt := NewMockT(t)
defer mt.Verify(FailureVerifier("^Assertion that one equals two failed: Unexpected.*"))
With(mt).
EnsureThat("one equals two").
ByVerifying(1).
Will(EqualTo(2)).Now()
})
}

Expand All @@ -45,7 +64,7 @@ func TestCorrectActualsPassedToMatcher(t *testing.T) {
mt := NewMockT(t)
defer mt.Verify(SuccessVerifier())
var actualsProvidedToMatcher []any
With(mt).Verify(tc.actuals...).Will(MatcherFunc(func(t T, actuals ...any) { actualsProvidedToMatcher = actuals })).OrFail()
With(mt).VerifyThat(tc.actuals...).Will(MatcherFunc(func(t T, actuals ...any) { actualsProvidedToMatcher = actuals })).Now()
if !cmp.Equal(tc.actuals, actualsProvidedToMatcher) {
t.Fatalf("Incorrect actuals given to Matcher: %s", cmp.Diff(tc.actuals, actualsProvidedToMatcher))
}
Expand All @@ -57,7 +76,7 @@ func TestMatcherFailureIsPropagated(t *testing.T) {
t.Parallel()
mt := NewMockT(t)
defer mt.Verify(FailureVerifier(`^expected failure(?m:\n^.+:\d+\s+-->\s+.+$){2}$`))
With(mt).Verify().Will(MatcherFunc(func(t T, a ...any) { t.Fatalf("expected failure") })).OrFail()
With(mt).VerifyThat().Will(MatcherFunc(func(t T, a ...any) { t.Fatalf("expected failure") })).Now()
}

func TestAssertionFor(t *testing.T) {
Expand Down Expand Up @@ -131,7 +150,7 @@ func TestAssertionFor(t *testing.T) {
t.Parallel()
mt := NewMockT(t)
defer mt.Verify(tc.verifier)
With(mt).Verify(tc.actuals...).Will(tc.matcherFactory()).For(tc.duration, tc.interval)
With(mt).VerifyThat(tc.actuals...).Will(tc.matcherFactory()).For(tc.duration, tc.interval)
})
}
t.Run("Matcher cleanups are called between intervals", func(t *testing.T) {
Expand All @@ -142,7 +161,7 @@ func TestAssertionFor(t *testing.T) {
cleanup1CallTime := time.Time{}
cleanup2CallTime := time.Time{}

With(mt).Verify(1).
With(mt).VerifyThat(1).
Will(MatcherFunc(func(t T, actuals ...any) {
t.Cleanup(func() { cleanup1CallTime = time.Now(); time.Sleep(1 * time.Second) })
t.Cleanup(func() { cleanup2CallTime = time.Now(); time.Sleep(1 * time.Second) })
Expand Down Expand Up @@ -208,7 +227,7 @@ func TestAssertionWithin(t *testing.T) {
mt := NewMockT(t)
defer mt.Verify(tc.verifier)
matcherFunc := tc.matcherFactory()
With(mt).Verify(tc.actuals...).Will(matcherFunc).Within(tc.duration, tc.interval)
With(mt).VerifyThat(tc.actuals...).Will(matcherFunc).Within(tc.duration, tc.interval)
})
}
t.Run("Success within duration is propagated", func(t *testing.T) {
Expand All @@ -217,7 +236,7 @@ func TestAssertionWithin(t *testing.T) {
defer mt.Verify(SuccessVerifier())
var firstCall time.Time
matcherFunc := MatcherFunc(func(t T, actuals ...any) { firstCall = time.Now(); time.Sleep(100 * time.Millisecond) })
With(mt).Verify(1).Will(matcherFunc).Within(5*time.Second, 100*time.Millisecond)
With(mt).VerifyThat(1).Will(matcherFunc).Within(5*time.Second, 100*time.Millisecond)
elapsedDuration := time.Since(firstCall)
if elapsedDuration > 1*time.Second {
t.Fatalf("Assertion should have succeeded much faster than 1 second: %s", elapsedDuration)
Expand All @@ -229,7 +248,7 @@ func TestAssertionWithin(t *testing.T) {
defer mt.Verify(SuccessVerifier())
invocations := 0
matcherFunc := MatcherFunc(func(t T, actuals ...any) { invocations++; time.Sleep(time.Second) })
With(mt).Verify(1).Will(matcherFunc).Within(10*time.Second, 100*time.Millisecond)
With(mt).VerifyThat(1).Will(matcherFunc).Within(10*time.Second, 100*time.Millisecond)
if invocations != 1 {
t.Fatalf("%d invocations occurred, but exactly one was expected", invocations)
}
Expand All @@ -244,7 +263,7 @@ func TestAssertionWithin(t *testing.T) {
t.Cleanup(func() { cleanup1CallTime = time.Now(); time.Sleep(1 * time.Second) })
t.Cleanup(func() { cleanup2CallTime = time.Now(); time.Sleep(1 * time.Second) })
})
With(mt).Verify(1).Will(matcherFunc).Within(5*time.Second, 100*time.Millisecond)
With(mt).VerifyThat(1).Will(matcherFunc).Within(5*time.Second, 100*time.Millisecond)
if cleanup1CallTime.IsZero() {
t.Fatalf("Cleanup 1 was not called")
}
Expand Down
Loading

0 comments on commit 0936ebf

Please sign in to comment.