-
Notifications
You must be signed in to change notification settings - Fork 150
Bring TestTx helper into rivershared for use in other projects
#814
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,18 @@ | ||
| package riversharedtest | ||
|
|
||
| import ( | ||
| "cmp" | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "log/slog" | ||
| "os" | ||
| "sync" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/jackc/pgx/v5" | ||
| "github.com/jackc/pgx/v5/pgxpool" | ||
| "github.com/stretchr/testify/require" | ||
| "go.uber.org/goleak" | ||
|
|
||
|
|
@@ -27,6 +32,47 @@ func BaseServiceArchetype(tb testing.TB) *baseservice.Archetype { | |
| } | ||
| } | ||
|
|
||
| // A pool and mutex to protect it, lazily initialized by TestTx. Once open, this | ||
| // pool is never explicitly closed, instead closing implicitly as the package | ||
| // tests finish. | ||
| var ( | ||
| dbPool *pgxpool.Pool //nolint:gochecknoglobals | ||
| dbPoolMu sync.RWMutex //nolint:gochecknoglobals | ||
| ) | ||
|
|
||
| // DBPool gets a lazily initialized database pool for `TEST_DATABASE_URL` or | ||
| // `river_test` if the former isn't specified. | ||
| func DBPool(ctx context.Context, tb testing.TB) *pgxpool.Pool { | ||
| tb.Helper() | ||
|
|
||
| tryPool := func() *pgxpool.Pool { | ||
| dbPoolMu.RLock() | ||
| defer dbPoolMu.RUnlock() | ||
| return dbPool | ||
| } | ||
|
|
||
| if dbPool := tryPool(); dbPool != nil { | ||
| return dbPool | ||
| } | ||
|
|
||
| dbPoolMu.Lock() | ||
| defer dbPoolMu.Unlock() | ||
|
|
||
| // Multiple goroutines may have passed the initial `nil` check on start | ||
| // up, so check once more to make sure pool hasn't been set yet. | ||
| if dbPool != nil { | ||
| return dbPool | ||
| } | ||
|
|
||
| dbPool, err := pgxpool.New(ctx, cmp.Or( | ||
| os.Getenv("TEST_DATABASE_URL"), | ||
| "postgres://localhost:5432/river_test", | ||
| )) | ||
| require.NoError(tb, err) | ||
|
|
||
| return dbPool | ||
| } | ||
|
|
||
| // Logger returns a logger suitable for use in tests. | ||
| // | ||
| // Defaults to informational verbosity. If env is set with `RIVER_DEBUG=true`, | ||
|
|
@@ -48,6 +94,69 @@ func LoggerWarn(tb testing.TB) *slog.Logger { | |
| return slogtest.NewLogger(tb, &slog.HandlerOptions{Level: slog.LevelWarn}) | ||
| } | ||
|
|
||
| // TestTx starts a test transaction that's rolled back automatically as the test | ||
| // case is cleaning itself up. | ||
| // | ||
| // This variant uses the default database pool from DBPool that points to | ||
| // `TEST_DATABASE_URL` or `river_test` if the former wasn't specified. | ||
| func TestTx(ctx context.Context, tb testing.TB) pgx.Tx { | ||
| tb.Helper() | ||
| return TestTxPool(ctx, tb, DBPool(ctx, tb)) | ||
| } | ||
|
|
||
| // TestTxPool starts a test transaction that's rolled back automatically as the | ||
| // test case is cleaning itself up. | ||
| // | ||
| // This variant starts the test transaction on the specified database pool. | ||
| func TestTxPool(ctx context.Context, tb testing.TB, dbPool *pgxpool.Pool) pgx.Tx { | ||
| tb.Helper() | ||
|
|
||
| tx, err := dbPool.Begin(ctx) | ||
| require.NoError(tb, err) | ||
|
|
||
| tb.Cleanup(func() { | ||
| // Tests may inerit context from `t.Context()` which is cancelled after | ||
| // tests run and before calling clean up. We need a non-cancelled | ||
| // context to issue rollback here, so use a bit of a bludgeon to do so | ||
| // with `context.WithoutCancel()`. | ||
| ctx := context.WithoutCancel(ctx) | ||
|
Comment on lines
+118
to
+122
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This of course means this rollback could hang ~indefinitely since it no longer inherits cancellation from
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah I suppose it's technically possible, but I think it's another one of those things that doesn't really happen in practice. I've had something similar to this in place at work for many months now over which time we've done millions of test runs and I've never seen it happen even one time. |
||
|
|
||
| err := tx.Rollback(ctx) | ||
|
|
||
| if err == nil { | ||
| return | ||
| } | ||
|
|
||
| // Try to look for an error on rollback because it does occasionally | ||
| // reveal a real problem in the way a test is written. However, allow | ||
| // tests to roll back their transaction early if they like, so ignore | ||
| // `ErrTxClosed`. | ||
| if errors.Is(err, pgx.ErrTxClosed) { | ||
| return | ||
| } | ||
|
|
||
| // In case of a cancelled context during a database operation, which | ||
| // happens in many tests, pgx seems to not only roll back the | ||
| // transaction, but closes the connection, and returns this error on | ||
| // rollback. Allow this error since it's hard to prevent it in our flows | ||
| // that use contexts heavily. | ||
| if err.Error() == "conn closed" { | ||
| return | ||
| } | ||
|
|
||
| // Similar to the above, but a newly appeared error that wraps the | ||
| // above. As far as I can tell, no error variables are available to use | ||
| // with `errors.Is`. | ||
| if err.Error() == "failed to deallocate cached statement(s): conn closed" { | ||
| return | ||
| } | ||
|
|
||
| require.NoError(tb, err) | ||
| }) | ||
|
|
||
| return tx | ||
| } | ||
|
|
||
| // TimeStub implements baseservice.TimeGeneratorWithStub to allow time to be | ||
| // stubbed in tests. | ||
| // | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if this should take an arg for DB URL rather than assuming a particular env? It makes it slightly more verbose to use but also much more flexible if other projects want to use their own schemas and DBs instead of sharing those used by the main River test suite.
It could even
require.NotEmptyon the URL so the caller doesn't need to use amustGetEnvtype util when calling it.I guess the downside is that this arg would need to cascade down through
TestTxandTestTxPool, whose APIs are more likely to be called in a lot of places.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, good idea. I went back and forth on these quite a lot because it's hard to find a good balance between good customizability and encouraging good default convention everywhere.
Just since this is specifically in
riversharedtest(strongly implying use in testing), why don't we see if we can get everything on the convention ofTEST_DATABASE_URL/river_test. In case this is turns out to be harde than expected, we can swap that out early for a more injectable env version.