Skip to content

proposal: testing: Add T.EndNow() #78048

@didrocks

Description

@didrocks

Proposal Details

We currently have T.FailNow(), T.Fatal() and T.SkipNow() to mark the state in a given state (failed or skipped) and stops its execution by calling runtime.Goexit. However, there is no easy way to stop the test early when succeeding or in the given state. Indeed, you can’t call https://pkg.go.dev/runtime#Goexit without T.finished (a private property) being set or the test will panic: https://github.com/golang/go/blob/master/src/testing/testing.go#L2033.

What I see often as a pattern is the following (in particular in parametric testing):

func TestMyFunc(t *testing.T) {
        tests := map[string]struct {
                /* some fixtures */

                /* more assertions */
                wantErr bool

        }{
                "Success": {},

                "Error on invalid input":           {wantErr: true},
        }

        for name, tc := range tests {
                t.Run(name, func(t *testing.T) {

                        got, err := package.MyFunc()
                        if tc.wantErr {
                                if err == nil {
                                   t.Fail("expected an error when calling MyFunc but got none")
                                }
                                return // The assertions later against got don’t matter as we expected to return an error.
                        }
                        if err != nil {
                            t.Fatalf("expected MyFunc to end up with no error but got: %v", err)
                        }                         

                        // Some assertions that are only valid if the API call succeedeed.
                })
        }
}

Similar to t.FailNow(), with a potential t.EndNow(), we can imagine then building an helper that does an early exit and assert the error is in the desired state by replacing the return in case we expect an error:

func errorMatchesOrStop(t *testing.T, wantErr bool, err error) {
    if wantErr {
        if err == nil {
            t.Fail("expected an error when calling MyFunc but got none")
        }
        t.EndNow()
    }
    if err != nil {
       t.Fatalf("expected MyFunc to end up with no error but got: %v", err)
     }
}

Which then simplifies the test to only focus on the important part: expected vs got, one both error and data assertion:

func TestMyFunc(t *testing.T) {
        tests := map[string]struct {
                /* some fixtures */

                /* more assertions */
                wantErr bool

        }{
                "Success": {},

                "Error on invalid input":           {wantErr: true},
        }

        for name, tc := range tests {
                t.Run(name, func(t *testing.T) {

                        got, err := package.MyFunc()

                        errorMatchesOrStop(t, tc.wantErr, er) // Error assertion.
                        // Some more assertions that are only valid if the API call succeedeed.
                })
        }
}

The implementation is quite simple, marking the T as finished (protected by mutexes) before calling runtime.Goexit(). This way, if the test previously is marked as failed, this will end the stop in failure mode, if it was previously marked as skipped, it will stop as skipped, otherwise, it will pass.

I’m happy to tackle this work myself if this addition to the testing package is accepted.

Let me know if anything is not clear enough.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions