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
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:

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

The API is now either:

    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:

- With(t).... .Now()
- With(t).... .For(...)
- With(t).... .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:

    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 committed Jun 23, 2024
1 parent 2458d4e commit 4c532fb
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 4c532fb

Please sign in to comment.